Skip to content

0.8.0 migration notes

Application authoring replaces plugin classes

Section titled “Application authoring replaces plugin classes”

The public plugin, action, stream and describe_plugin imports have been removed. Replace a decorated plugin class with app = QgisSdkPlugin(id=..., title=...) and turn each action or stream method into a module function registered with @app.action(...) or @app.stream(...). Give each function a first positional ctx: PluginContext argument and use ctx rather than self.ctx. Actions and streams need explicit IDs and titles; plugin metadata can default from the module, but explicit stable IDs are recommended. Register surfaces with @app.surface or app.add_surface(...) and providers with app.add_provider(...); compose other QgisSdkPlugin objects using app.include(child). Change the manifest and bridge generation target from module:Class to module:app, and use describe(app) or app.describe(). PluginRuntime, DesktopLifecycleAdapter, create_class_factory and describe_bindings now take an application rather than a plugin class; FakeUi(scope, app=app) checks its registered surfaces in pure tests. The class-specific PluginFactory, instance_factory and declared_providers APIs have been removed. For per-host context construction, assign app.context_factory. The old class-constructor hooks have no direct replacement; construct testable dependencies in the context factory or in ordinary functions.

The plugin HTTP API, and MCP for development only

Section titled “The plugin HTTP API, and MCP for development only”

This release adds one runtime interface for clients outside QGIS and keeps MCP strictly on the development side. No released version shipped the experimental qgis_sdk.agent module, so there is nothing to migrate: it is not part of this release, and there is no [tool.qgis-sdk.agent] metadata, AGENT check rule, dev --agent mode or consent surface.

Calling a plugin from outside QGIS. A host starts the listener itself, after runtime.start() and on the runtime’s owner thread:

from qgis_sdk.http import PluginHttpServer
server = PluginHttpServer(
runtime,
post=post_to_owner_thread,
token_file=private_directory / "plugin.token",
actions=("buffer", "watch_progress"), # an allowlist, not a view filter
)
server.start()
# ... serve ...
server.close()

actions must name IDs the plugin actually declared; an unknown ID is refused at construction. Nothing is exposed that the host does not name, construction opens no socket and creates no token, and only one server may serve a runtime.

  • New names: qgis_sdk.http.PluginHttpServer, QgisSdkHttpError; qgis_sdk_tools.rest.PluginClient, PluginClientError; and the developer commands qgis-sdk plugin describe|call|stream.
  • Endpoints: GET /plugin (the plugin description filtered to the allowlist), POST /actions/<id> (a JSON object in, the action’s return value out), and POST /streams/<id> (server-sent events, ending with a terminal frame).
  • Calls are serialized per runtime. A second call waits for its turn for up to the server’s deadline and then fails with 503 and the host_busy code; it never runs inside another handler, including one that opened a modal dialog. Work queued from the thread already running plugin work is refused at once. A reload fails an in-flight call with host_reloaded without undoing effects it already had.
  • Closing withdraws queued work, and the callers it withdrew answer 503 with host_reloaded before the connections are shut down: close() waits for the handlers already running, for up to close_grace seconds (2 by default). A handler still running after that grace is cut off, so a close never hangs on a stuck action.
  • Refusals carry {"error": {"code": ..., "message": ...}}. host_busy, host_reloaded and the per-plugin session capacity budget answer 503, because waiting or retrying can succeed; malformed input and failed operations answer 400. PluginClient raises PluginClientError carrying the status for any non-200 reply, refuses a response larger than 1 MiB instead of parsing a truncated body, and reports a 200 action reply without a value as PluginClientError rather than leaking a KeyError.
  • PluginHttpServer.pending_requests and PluginHttpServer.busy report the dispatch state without exposing the internal queue: how many requests wait for the owner thread and whether one is running right now.
  • Every dispatch still runs on the QGIS owner thread and through the same dispatcher, authority and protocol version as the page and the console. The bridge, BRIDGE_PROTOCOL_VERSION and HOST_FINGERPRINT do not move.

Security model. The listener binds to 127.0.0.1 only, refuses a foreign Host header and any browser Origin, and requires a bearer token that the host writes to a mode-0600 file, unlinks on close, and never puts on a command line the plugin controls. Connections, request size, read and write deadlines, event-stream slots and buffered bytes are all bounded. The allowlist is a permission list, not a sandbox: an exposed action runs with the plugin’s own authority, and the token holder can call anything the host named.

Support boundary: POSIX only. Creating a private token file relies on POSIX permissions, and Windows credential storage is not implemented. On a non-POSIX platform PluginHttpServer.start() refuses with a QgisSdkHttpError naming the limitation, and creates no token or listener, rather than starting one whose token another local account could read. This is a deliberate scope decision for this release, not a transient bug; every other SDK feature, including the CLI MCP server, works on Windows.

qgis-sdk mcp. pip install 'qgis-sdk[mcp]' adds the official mcp package as an optional extra, and qgis-sdk mcp serves the developer CLI commands over stdio. The dependency is imported only when that command runs; a missing extra exits with the install line. This server never connects to a running plugin, and the plugin REST commands are deliberately absent from its inventory: use qgis-sdk plugin or PluginClient for that.

Externals with a probe, and pure-Python vendoring

Section titled “Externals with a probe, and pure-Python vendoring”

[tool.qgis-sdk.build] gains two keys (decision-50):

  • external accepts tables beside bare roots: { import = "pandas", requirement = "pandas>=2", reason = "reads spreadsheets" }. requirement defaults to the import root; reason is the sentence the startup probe shows. The declared roots move the matching DEP001 finding into the external bucket, appear in metadata.txt as Requires: pandas (pandas>=2) - ..., and the generated classFactory - for build and dev alike - imports each one before anything of the plugin, explains a missing package in one message-bar error with the install command for the host, and returns None so QGIS leaves the plugin unloaded. Nothing is installed on the user’s machine.
  • vendor lists installed pure-Python distributions the build copies into <plugin>/_vendor/, with imports of those roots rewritten (in the plugin and inside the vendored code), licences under _vendor/LICENSES/ and name/version in _vendor/NOTICE. A compiled distribution is DEP003; a requirement that is neither vendored nor external is DEP004.

New names: qgis_sdk_tools.project.BuildConfig and ExternalPackage (ProjectConfig.build, external_roots), qgis_sdk_tools.distributions (Distribution, read_distribution, normalize, file_path, QGisSdkDistributionError), qgis_sdk_tools.check.buckets (BucketReport, ImportEntry, bucket_imports, import_findings, the BUCKETS constants), CheckReport.imports, BuildResult.vendored, and check --imports. An import of a declared vendored root is now vendored instead of unknown, so a project that vendored by hand and declared nothing keeps its DEP001 until it declares the distribution.

Algorithms.run no longer forwards arbitrary named destinations directly to Processing. Use an omitted destination for an SDK-owned output, or provide one new local .geojson or .gpkg vector destination. Existing files (even with an overwrite-enabled FileRef), multiple named destinations, memory/remote URIs, raster, folder and other named outputs are refused. For intentional vector replacement, run into an allocated output and use Layers.export_vector with explicit overwrite consent. Other formats need a separately qualified publication workflow. See Processing output safety for the provider trust and cleanup limitations.