Files
WeKnora/pluginsdk/python/README.md
T
wizardchen d46fa25e41 feat(plugin): dynamic form options, OAuth connections and resource limits
Plugin config forms can now ask the plugin while they are filled in:

- x-options {name, dependsOn, search} loads a field's choices from
  /v1/options/{name}. POST /api/v1/plugins/:id/options merges the form's
  values with the stored secrets (system, tenant or instance scope) so a
  redacted token still works. Go and Python SDKs, OpenAPI and conformance
  cover the endpoint.
- x-oauth turns a string field into an account connection. WeKnora runs
  the authorization code flow (PKCE, client credentials from the plugin's
  system config), keeps the tokens sealed in plugin_oauth_connections and
  the field holds "oauth:<id>". The invoker swaps references for fresh
  access tokens at call time, refreshing near expiry. Callback:
  /api/v1/plugin-oauth/callback, one-time states in Redis or memory.
- SchemaForm gains an options widget (dependsOn reload, remote search)
  and an OAuth connect widget; connector, web search, workspace and
  platform config forms provide the plugin behind them.

Host plugins are capped per runtime.resources on Linux: RLIMIT_DATA for
memory (WEKNORA_PLUGIN_MEMORY_DEFAULT when undeclared) and, with a
delegated cgroup v2 (WEKNORA_PLUGIN_CGROUP), memory.max and cpu.max.
2026-09-26 11:51:35 +08:00

5.5 KiB

weknora-plugin

Build WeKnora code plugins in Python. The package speaks the same extension protocol as the Go SDK. It uses only the standard library and supports Python 3.9 and later.

from weknora_plugin import Plugin, SearchResult

plugin = Plugin("acme.search", "1.0.0")  # must match plugin.yaml


@plugin.web_search("web")
def search(call, q):
    # call.tenant_id, call.locale, call.system / call.tenant / call.instance
    return [SearchResult(title=q.query, url="https://example.com")]


if __name__ == "__main__":
    plugin.serve()

Contributions

Web search.

@plugin.web_search(id)
def search(call, q: SearchInput) -> list[SearchResult]: ...

Parser. Return a ParseOutput, or just the Markdown as a str.

@plugin.parser(id)
def parse(call, doc: ParseInput) -> ParseOutput: ...
  • doc.content is the file as bytes.
  • Images the Markdown references as ![](ref) go in images with a matching original_ref.

Connector. A class, registered with @plugin.connector(id) (or pass an instance):

@plugin.connector("notes")
class Notes:
    def validate(self, call, cfg: ConnectorConfig) -> None: ...
    def list_resources(self, call, cfg, parent_id: str) -> list[Resource]: ...
    def fetch(self, call, cfg, inp: FetchInput, stream: Stream) -> Cursor | None:
        for note in changed_since(inp.cursor):
            stream.item(FetchedItem(external_id=note.id, title=note.title, content=note.markdown))
        stream.checkpoint(Cursor(state={"after": last_id}))  # at page boundaries
        return Cursor(state={"after": last_id})
    # optional: resolve_ancestors(self, call, cfg, resource_ids) -> list[str]

Configuration check.

@plugin.config_validator
def validate(call) -> None: ...

Pages. Requests from the plugin's pages (pages, settingsSections, kbTabs in plugin.yaml) arrive at one handler. Return a UIResponse, or any JSON value for a 200:

@plugin.ui
def ui(call, req: UIRequest):
    # req.mount ("pages/links"), req.method, req.path, req.body, req.role
    return UIResponse(status=200, body=links)

The pages themselves are HTML under ui/ that use @weknora/plugin-ui.

Events and webhooks.

@plugin.on_event
def on_event(call, ev: EventDelivery):   # permissions.events; dedupe on ev.id
    ...

@plugin.webhook("notes")                 # contributes.webhooks
def notes(call, req: WebhookRequest):    # req.headers, req.body, req.json()
    return WebhookResponse(status=204)

Dynamic choices. A field with x-options: {name: projects} in a config schema gets its choices from the plugin. Return Options or (value, label) pairs:

@plugin.options("projects")
def projects(call, inp: OptionsInput):
    # inp.scope, inp.field, inp.query; the form's values are in call.tenant / call.instance
    if not call.tenant.get("token"):
        raise invalid_config("enter the token first", {"token": "required"})
    return [("p1", "Project one"), ("p2", "Project two")]

OAuth. An x-oauth field needs no code: WeKnora runs the flow, and the plugin finds a fresh access token where the field is (see the Go SDK README).

Errors

Raise PluginError(ErrorCode.X, "message") to choose what WeKnora sees:

  • unauthorized: credentials stopped working.
  • rate_limited (with retry_after=): back off.
  • unavailable: retry later.
  • invalid_config(message, {"settings.url": "required"}): point at form fields.

Any other exception becomes internal, and its traceback goes to stderr.

Calling back into WeKnora

A plugin granted Host API scopes (permissions.hostApi in plugin.yaml) gets a client per call from call.host(). It returns None without a grant.

host = call.host()
host.kv_put("cursor", {"page": 3}, ttl=3600)
host.kv_get("cursor", default={})

The token behind it lasts a few minutes: use it within the call.

Running

plugin.serve() picks its mode from the environment.

  • Host mode. The WeKnora plugin host starts the plugin with WEKNORA_PLUGIN_SOCKET and WEKNORA_PLUGIN_TOKEN. The plugin listens on that socket and prints the handshake on stdout. Keep stdout free of anything else, and log to stderr (plugin.logger).
  • Remote mode. Run on its own, it listens on WEKNORA_PLUGIN_ADDR (default :8080). It accepts only requests signed with WEKNORA_PLUGIN_SECRET, the secret shown when the plugin was registered.

On SIGTERM it stops accepting calls and lets the ones in flight finish. Each thread handles one call. call.deadline says when WeKnora stops waiting.

Packaging for the plugin host

runtime: { type: host, kind: python, entry: main.py }

WeKnora runs the entry with its own python3. It puts the package's vendor/ directory and the package root on PYTHONPATH. Vendor the SDK and any dependencies:

pip install --target vendor weknora-plugin   # or copy src/weknora_plugin

Pure-Python dependencies work everywhere. Ones with native code must match the server's platform. For a complete plugin, see examples/plugins/notebooks (a Jupyter notebook parser).

Testing

plugin.test_server() serves the plugin on a loopback port without authentication, for unit tests. To check the protocol end to end, run the conformance suite from the Go SDK against a running plugin:

go run github.com/Tencent/WeKnora/pluginsdk/cmd/weknora-plugin-conformance -url http://localhost:8080 -secret $SECRET