Example: a Notes Plugin
A complete small Plugin with a Python half that reads a Project file over RPC and a web half that shows it as a Console channel.
This Plugin shows the lines of the Project's notes.txt as a channel in the
Console. It is small, but it uses each part of a Plugin: a manifest, a Python
half that registers an RPC method, a web half that registers into another
Plugin's slot, and tests for both halves. It was written from the
Plugin development pages alone, and Studio's own test
suite loads it, so the code on this page runs against the current API.
Files#
witwin_example_notes/
witwin.toml the manifest
__init__.py Python half: activate(ctx) registers one RPC method
web/viewport.js web half: a Console channel through the console.channels slotThe manifest#
id = "witwin.example_notes"
name = "Example Notes"
version = "0.1.0"
description = "Shows the Project's notes.txt as a Console channel."
icon = "notebook"
dependencies = []
# The web half registers into a slot through the proposed `slots` service,
# so it works only while loaded from a local path.
enabledApiProposals = ["slots"]
# Optional: without the Console this Plugin still activates; its channel
# waits in the slot and appears when the Console is enabled.
[[pluginDependencies]]
id = "studio.console"
version = "^1"
optional = true
[engines]
witwin = "^2.0.0"
[web]
root = "web"
[contributes.viewport.host]
entry = "viewport.js"
sdkVersion = "2.0.0"
[permissions]
frontend = true
project_files = "read"
[agent]
summary = "Shows the Project's notes.txt in the Console."Three choices in this file shape the rest of the Plugin:
studio.consoleis an optional dependency. The Console is a system Plugin that a Project can turn off; this Plugin still activates without it, and its channel attaches when the Console is on.enabledApiProposals = ["slots"]asks for the proposedslotsservice. Proposed services work only for a Plugin loaded from a local path, so this Plugin has to move to stable services before it can be released.[permissions]discloses what the Plugin uses: a web half and read access to Project files. Studio shows this list before an installed copy of the Plugin runs; while you load it from a local path, it runs without that step.
Every key is explained in The manifest.
The Python half#
from __future__ import annotations
from pydantic import BaseModel
from witwin_server.api import PluginContext, ProjectFs, ProjectPathError, RpcError, get_logger
logger = get_logger("ExampleNotes")
# Third-party RPC names live under "<plugin id>.".
READ_METHOD = "witwin.example_notes.read"
class ReadParams(BaseModel):
# A Project path (forward slashes, relative to the Project root), never an OS path.
path: str = "notes.txt"
class Notes(BaseModel):
lines: list[str]
def read_notes(fs: ProjectFs, params: ReadParams) -> Notes:
"""The lines of ``params.path``; no file means no lines, a bad path is ``invalid_params``."""
try:
text = fs.read_text(params.path) if fs.is_file(params.path) else ""
except ProjectPathError as error:
raise RpcError("invalid_params", str(error)) from error
return Notes(lines=text.splitlines())
def activate(ctx: PluginContext) -> None:
# The registry reads the pydantic models from these annotations.
def read(_rpc_ctx, params: ReadParams) -> Notes:
return read_notes(ctx.fs, params)
# lock="none": reading a text file does not need to wait for Scene writes.
ctx.rpc.register(READ_METHOD, read, scope="project.read", lock="none", idempotent=True)
logger.debug("registered %s", READ_METHOD)The Python half runs in the ProjectServer. It imports only
witwin_server.api, the stable layer, which is what the boundary check
enforces. It reads the file through ctx.fs with a Project path rather than
an OS path, so the same code works for a local Project and a cloud Project.
See Python API and
Files and the network.
The web half#
export const sdkVersion = '2.0.0';
const PLUGIN_ID = 'witwin.example_notes';
const READ_METHOD = `${PLUGIN_ID}.read`;
/** @type {import('@witwin/host').PluginDefinition} */
const plugin = {
id: PLUGIN_ID,
inject: ['rpc', 'slots', 'commands'],
activate(host) {
const rpc = host.services.get('rpc');
const listeners = new Set();
/** @type {import('@witwin/workbench').ConsoleEntry[]} */
let entries = [];
const reload = async () => {
const { lines } = await rpc.call(READ_METHOD, {});
const now = new Date().toISOString();
entries = lines.map((text, index) => ({ id: `note-${index}`, timestamp: now, level: 'info', source: 'notes', text }));
listeners.forEach((listener) => listener());
};
host.services.get('slots').register('console.channels', {
channel: { id: PLUGIN_ID, label: 'Notes' },
entries: () => entries,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
});
host.registerCommand({ id: `${PLUGIN_ID}.reload`, title: 'Reload notes', category: 'Notes' }, reload);
// The first load must not fail activation when the server is still starting.
return reload().catch(() => undefined);
},
};
export default plugin;The web half is a plain ES module: it bundles no React and imports nothing
from Studio. It calls the Python half through the stable rpc service and
adds its entry to console.channels, a slot the Console declares. Because
the dependency on studio.console is optional, the entry waits while the
Console is off. See Web API and
Slots, events and URIs.
Load it in Studio#
Add the folder as a local Plugin
On the Plugins page, open the + Add menu, choose Add Local Plugin Path and pick the
witwin_example_notesfolder.Add a notes file
Create
notes.txtin the Project root, then run the Reload notes command. The Console shows a Notes channel with the file's lines.
Test it#
The Plugin has one test of each kind that Testing a Plugin
describes. The Python tests load it with Studio's real loader through
witwin_server.api.testing:
from pathlib import Path
from witwin_server.api import testing
PLUGIN_ROOT = Path(__file__).resolve().parent.parent # the Plugin folder
READ_METHOD = "witwin.example_notes.read"
def test_example_manifest_declares_the_optional_console_dependency():
manifest = testing.read_manifest(PLUGIN_ROOT / "witwin.toml")
assert manifest.id == "witwin.example_notes"
console = [dep for dep in manifest.plugin_dependencies if dep.get("id") == "studio.console"]
assert console and console[0].get("optional") is True
def test_example_plugin_activates(monkeypatch):
monkeypatch.syspath_prepend(str(PLUGIN_ROOT.parent))
with testing.load_plugin(PLUGIN_ROOT) as plugin:
assert plugin.error is None
assert plugin.activated