Skip to content

Desktop host: host() and the project manifest

Since 0.6.0 a plugin project carries no metadata.txt, no classFactory and no view module (decision-43). pyproject.toml names the application and the tools derive what QGIS needs from it:

[project]
name = "field-tools"
version = "0.1.0"
description = "Field Tools: a minimal qgis-sdk plugin."
authors = [{ name = "Ada", email = "ada@example.test" }]
dependencies = ["qgis-sdk-runtime>=0.6,<0.7"] # the runtime `build` resolves
[tool.qgis-sdk]
plugin = "field_tools.plugin:app" # the application QGIS runs
qgis-minimum-version = "4.0"
experimental = true
# qgis-prefix = "/path/to/qgis" # where `test`/`dev` find QGIS
[tool.qgis-sdk.metadata] # optional, passed through
homepage = "https://example.org/field-tools"
[tool.qgis-sdk.web] # optional: the page in web/
placement = "dock" # dock | dialog
grants = ["layers.read", "project.layer_ids"] # host grants or operation ids

qgis_sdk.adapters.desktop.host(spec, *, placement=None, grants=()) is the runtime half - the classFactory QGIS calls:

from qgis_sdk.adapters.desktop import host
classFactory = host("field_tools.plugin:app", placement="dock")

It resolves the application (a typo fails by name, with QgisSdkHostError), validates its declarations, and returns a factory. The DesktopHost it makes starts the runtime from initGui(), registers the application’s providers with Processing, and unloads everything from unload() (safe to repeat). The context comes from one of two places: a context_factory(iface) attribute on the application when the author wants to assemble services; otherwise default_context, which is native_plugin_context(iface, ...) whenever a QgsApplication is running and a bare PluginContext (messages dropped, settings scoped by plugin id) when none is - so the same factory runs the starter tests without QGIS, and ctx.desktop is refused by name there instead of inventing a host.

With a placement, initGui() adds one toolbar button and show_view() opens the built page from <package>/web/index.html in a QWebEngineView as a QDockWidget or QDialog (view id <plugin id>.main), with a Dispatcher over the running runtime and the curated host operations the grants select - a grant such as layers.read selects every operation that needs it, an operation id selects one (granted_operations, then register_host_operations(only=...)): no grants, no host operation on the channel at all. qgis-sdk dev serves the same page from Vite when it has started one (QGIS_SDK_DEV_SERVER). host.view is the widget while it is open, None after unload().

create_class_factory(app, context_factory=...) and DesktopLifecycleAdapter remain for a plugin that supplies its own entry point; host() is built on them. Neither imports Qt at module level; the adapter never serialises iface.

The headless harnesses and the real-Desktop evidence for this adapter are in doc-14.

[tool.qgis-sdk] hosts says which hosts one plugin class runs on (decision-52): desktop (a QGIS Desktop plugin loaded through classFactory, the default), server (a QGIS Server plugin loaded through serverClassFactory) and app (a standalone PyQGIS application started by qgis_sdk.app.run). The set changes nothing about how the class is written - it is what check reads when it reports HOST001 (a Desktop-only declaration in a plugin that does not declare Desktop), HOST002 (a server hook in a plugin that does not declare the server) and HOST003 (the server host declared against a QGIS built without QGIS Server, which is a statement about the environment, not the project).

[tool.qgis-sdk]
plugin = "field_tools.plugin:app"
qgis-minimum-version = "4.0"
hosts = ["desktop"] # desktop | server | app; default ["desktop"]

Host detection is explicit, never inferred from an iface, a QgsApplication or an environment variable: the host that builds a context says which one it is, and ctx.host reports it - Host.DESKTOP, Host.SERVER or Host.APP from qgis_sdk.core, compared as strings because it is a StrEnum. ctx.host.gui is False on a server, which is the honest answer to “may I open a window here?”. A view inherits the host of the plugin context that opened it. All three hosts are implemented - Desktop below, the server’s hook layer in the next section, and the standalone app after it - and a capability any of them lacks still refuses by name (decision-39). The reference example demonstrates both of the hosts 0.9.0 adds: examples/simple carries an app entry beside its Desktop declaration, and examples/server is its server variant - the same point fixture, answered by one OGC service.

The server host: the hooks QGIS Server calls

Section titled “The server host: the hooks QGIS Server calls”

A server plugin declares hooks instead of surfaces. qgis_sdk.server gives it three decorators, each of which takes ctx: ServerContext first, because that is how the host knows what it is calling:

from qgis_sdk.server import ServerContext, ServerRequest, ServerResponse, access_control, filter, service
@filter(priority=10) # before the services run
def add_version(ctx: ServerContext, request: ServerRequest, response: ServerResponse):
if request.param("VERSION") is None:
return request.with_params(VERSION="1.3.0")
return None # None passes, a request rewrites,
# a ServerResponse answers and stops
@service(name="SDKDEMO", version="1.0.0") # answers the SERVICE it names
def demo(ctx: ServerContext, request: ServerRequest) -> ServerResponse:
return ServerResponse.xml("<demo/>")
@access_control(priority=20) # asked once per requested layer
def only_public(ctx: ServerContext, request: ServerRequest, layer: str) -> bool:
return layer != "secret"

ServerHooks collects the declarations, fixes their order once - priority ascending, then the plugin class’s declaration order, then include order - and refuses at startup what QGIS would refuse at the first request: an async def, a hook that does not annotate ctx as ServerContext, two services claiming one name, a non-int priority.

Registering them with a real server is one call:

# __init__.py of a server-only plugin
from qgis_sdk.adapters.qgis.server import QgsServerHost
from qgis_sdk.server import ServerHooks
from .plugin import add_version, demo, only_public
HOOKS = ServerHooks((add_version, demo, only_public))
def serverClassFactory(serverIface):
return QgsServerHost(HOOKS, serverIface).register()

qgis-sdk new --host server writes exactly that file (below), so a project that starts as a server plugin keeps the entry QGIS Server imports.

SDK declaration the object QGIS calls when
every @filter one QgsServerFilter, carrying the whole chain onRequestReady, at the lowest priority any filter declares
each @service one QgsService executeRequest, when the request names that SERVICE and version
every @access_control one QgsAccessControlFilter layerPermissions, per layer, while a service renders

Three things that mapping decides for you, and says so:

  • A filter can rewrite, and can answer. A rewrite is written into the request handler, so QGIS’s own services see the new value. An answer is written over the OGC error setServiceException leaves behind - that flag is the only switch that makes the C++ dispatcher skip its own services for a request a plugin has already answered.
  • request.method is empty on the filter path. QgsServerFilter receives no request object, and QgsRequestHandler exposes the URL, the headers and the merged OGC parameters but not the verb. A hook that must tell a GET from a POST belongs in a @server.service, where QGIS does hand over the QgsServerRequest.
  • A refused layer is a refusal, not a filtered map. The access hook answers False and the adapter clears all four permissions; it never silently narrows what the client asked for. That decision costs the capabilities cache while the plugin is loaded: the adapter answers cacheKey() with a non-empty string, which QgsAccessControl::fillCacheKey reads as “do not reuse a document”, so a capabilities document shaped by one request is never handed to another.
  • The server adapter imports without Qt. qgis_sdk.adapters.qgis resolves a name the first time it is asked for, so ...adapters.qgis.server imports on a machine where QGIS is not installed at all. Two things follow: the generated entry can be driven against the qgis.server stand-ins in a pure test, and a server-only deployment never drags Qt Widgets or Processing into the process that answers requests.

Jobs on this host: the request is the clock

Section titled “Jobs on this host: the request is the clock”

A server has no GUI thread and no job pump, so ctx.jobs on a ServerContext is not the plugin’s service: it is one derived over the request’s scope, and the request is what drives it.

@filter()
def schedule(ctx: ServerContext, request: ServerRequest, response: ServerResponse):
handle = ctx.jobs.summarise.delay(layer="parcels") # this request's job
handle.on_cancelled(lambda: ctx.log.info("stopped with the response"))
ctx.jobs.plugin.rebuild_index.delay() # outlives the request
  • Ticked at request start. A server has no event loop, so the host ticks the plugin’s job service when a request begins: a job that request submits starts on the next request, never in the background.
  • Abandoned when the response is sent. The request scope closes at that moment and its jobs are abandoned: each handle reads JobStatus.CANCELLED immediately, owner_closed is True, a worker still running is asked to stop, no callback runs, and the plugin’s scheduler stops holding the job. That is a zero-deadline quiesce - nothing in a request may wait for a worker - which is why the outcome is the host’s word rather than a worker report that will never arrive (decision-52).
  • Plugin-owned work says so. ctx.jobs.plugin submits with the plugin’s lifetime and survives the response, exactly as it does inside a view. It still needs something the operator started to drive it: the SDK does not invent a background thread for a server.
  • A host with no job service refuses by name. ctx.jobs is a capability, so a deployment given no job service answers QgisSdkUnsupportedCapabilityError naming jobs instead of handing out a service that could submit nothing.

The whole dispatcher is testable without QGIS: qgis_sdk.testing.FakeServerInterface records registrations and serves a request through the same ServerHost, so a project’s own qgis-sdk test (the pure target) proves the hooks on a machine whose QGIS has no server bindings at all. Where the build does have them, the same plugin runs in-process under a headless QgsApplication through QgsServer.handleRequest - the QGIS 3.44 LTS row runs exactly that, and doc-14 carries the evidence.

The app host: the same plugin as a standalone PyQGIS application

Section titled “The app host: the same plugin as a standalone PyQGIS application”

qgis_sdk.app.run owns the QgsApplication instead of being called by one. The class does not change: the same QgisSdkPlugin, the same actions, the same jobs, the same bridge. What changes is who starts it and what it may attach to.

# simple/__main__.py — the entry `python -m simple`, `qgis-sdk run` and a built
# distribution's console script all call.
from qgis_sdk.app import main as run_app
from .plugin import app
def main(argv=None) -> int:
return run_app(app, argv)
if __name__ == "__main__":
raise SystemExit(main())

run_app(app, argv) reads the flags (qgis_sdk.app._options) and calls run(app, gui=..., argv=..., grace=..., once=...); the launcher never parses argv itself.

Flag What it does
(none) a window: a QMainWindow with a QgsMapCanvas, a toolbar and a status bar
--headless no window and no ctx.ui; jobs, the project, the services and the bridge’s model layer still work
--once start, deliver what is already queued, stop - the smoke run a CI job makes
--grace SECONDS how long shutdown waits for running jobs (default 15)
--title TITLE the window title, when the application’s own is not what a launcher wants
-- everything after it goes to the toolkit, not the SDK

ctx.host is Host.APP in both modes. Everything a plugin needs without a window is there: ctx.project, ctx.layers, ctx.algorithms, ctx.styles, ctx.files, ctx.settings, ctx.network, ctx.notifications (the QGIS message log and the window’s status bar), and ctx.jobs driven by the app’s own job pump. A capability the run does not have refuses by name, as everywhere else:

run(app) # ctx.ui exists; ctx.desktop refuses - there is no iface
run(app, gui=True) # a window: @toolbar/@menu bindings become actions on it,
# Dialog and Dock surfaces open inside it

ctx.desktop refuses in an app because there is no QgisInterface to wrap - qgis_sdk.app does not fake one. A plugin that needs ctx.desktop.iface (the QGIS message bar, an existing dialog, the layer tree) is a Desktop plugin; one that only needs the services, the job registry and the canvas is one class for both hosts.

The window is deliberately small: a QMainWindow, one QgsMapCanvas with an empty project, a toolbar, a status bar, and the same qgis_sdk.adapters.surfaces.SurfaceHost the Desktop adapter places dialogs with. There is no layer tree, no menu from QGIS and no plugin manager - those are QGIS Desktop’s, not the SDK’s. A @menu("Plugins/Field tools") binding lands in a submenu of that name, exactly as it does in QGIS: AppWindowInterface answers addPluginToMenu(name, action) the way iface does, opening the Plugins, Vector, Raster, Database, Web and Mesh menus on first use and taking an entry back out - submenu first, then the family menu once it is empty - when the plugin stops. gui is settled first because Qt fixes it at construction: a window may only exist under a QgsApplication created with GUIenabled, so run(app) creates the application without it and run(app, gui=True) with it. The later canvas service (TASK-85: extent, zoom, map themes, capture) attaches to this canvas and to the Desktop one through the same interface.

Lifecycle, and the difference from a Desktop plugin

Section titled “Lifecycle, and the difference from a Desktop plugin”
QGIS Desktop plugin Standalone app
Who creates QgsApplication QGIS qgis_sdk.app.run (initQgis / exitQgis)
Who calls the entry classFactory(iface) from the plugin manager the launcher’s main(argv)
ctx.host desktop app
ctx.desktop the live iface refuses by name
ctx.ui always only with gui=True
Who drives the job pump QGIS’s Qt loop the app’s own QTimer, named job-pump
Shutdown the plugin manager unloads it inside a running QGIS run stops the loops, drains jobs, closes the window and exits QGIS once
Reload / enable / disable QGIS’s lifecycle none: restart the process

The bounds of that last row are decision-69 points 2 and 4:

  1. every background loop the host started is stopped by name - the job pump first, then the surface’s own timers - and the shutdown report lists them;
  2. running jobs get grace seconds (15 by default) to settle; whatever is left is named, asked to stop and reported as cancelled, and none of its callbacks run afterwards;
  3. the UI closes, the runtime unloads, and exitQgis() is called exactly once - including when the start failed halfway, and never when initQgis() never ran.
from qgis_sdk.app import AppSession
from qgis_sdk.adapters.app import QtAppBackend
session = AppSession(app, backend=QtAppBackend(), gui=False, grace=5.0, say=print)
session.start()
session.runtime.actions["open_fixture"]()
report = session.stop()
print(report.summary()) # stopped 1 loop: job-pump; drained 1 job
print(report.to_json()) # the same facts, for a log or a test

qgis_sdk.app.quit() leaves the event loop from inside a job or an action - the one hook a headless app needs when its work is done. Called with no app running it refuses by name instead of touching a QgsApplication that belongs to QGIS Desktop.

Three routes, one entry:

Terminal window
# 1. inside the project, with whatever interpreter has PyQGIS
PYTHONPATH=. python -m simple # a window
PYTHONPATH=. python -m simple --headless --once # starts and stops; CI's smoke run
# 2. the CLI finds the QGIS install (QGIS_PREFIX_PATH, `qgis` on PATH, this
# environment or [tool.qgis-sdk] qgis-prefix) and runs the entry with it
qgis-sdk run # a window
qgis-sdk run --headless --once # headless smoke run
qgis-sdk run --grace 5 --title "Field tools"
# 3. a distribution with a console script
qgis-sdk build --target app
pip install dist/simple-0.1.0-app
simple # the same entry, the same flags

qgis-sdk run never installs anything and never writes into the project: it puts the found install’s PyQGIS and the SDK’s own packages on that interpreter’s PYTHONPATH, runs the project’s <plugin>/__main__.py in the project root, and passes --headless, --once, --grace and --title through. A project without an entry (a Desktop plugin) is started through a generated probe in a temporary directory that imports [tool.qgis-sdk] plugin and calls the same qgis_sdk.app.main, so trying a plugin as an app costs nothing.

The default qgis-sdk build writes the plugin ZIP QGIS’s Install from ZIP reads. An app has no such caller, so --target app writes a Python project instead:

dist/simple-0.1.0-app/
pyproject.toml # [project.scripts] simple = "simple._launcher:main"
simple/
__main__.py # the project's own entry, untouched
_launcher.py # generated: probes externals, then qgis_sdk.app.main
... # the plugin's modules, imports rewritten
_vendor/
qgis_sdk/ # the runtime, same vendoring rules as the ZIP
NOTICE # which runtime, and every vendored distribution
  • the runtime is vendored (<plugin>._vendor.qgis_sdk, imports rewritten by the same AST pass the ZIP build uses), so the distribution declares no runtime dependency and works wherever PyQGIS already is;
  • declared externals ([tool.qgis-sdk.build] external) are probed before the application is imported: a missing one is one line naming the package and pip install <requirement>, exit code 3, instead of a traceback from inside a plugin (decision-50 point 1);
  • pure-Python distributions listed in [tool.qgis-sdk.build] vendor are copied beside the runtime under the same rules;
  • the runtime’s data files (the Processing catalogue, the reference styles) are listed in [tool.setuptools.package-data], file by file, so a wheel built anywhere carries them.

Nothing is installed by build, and nothing in the source tree is edited: the directory is the input to pip install. Install it into the QGIS interpretation that already has PyQGIS - a distribution installed into a Python with no qgis.core refuses by name (this interpreter cannot import PyQGIS) rather than crashing, because that is the common mistake.

Terminal window
qgis-sdk new field_tools --host app
qgis-sdk new field_tools --host app --ui native # a Dialog on the app window
qgis-sdk new field_tools --host app --algorithm # a Processing provider

The generated project declares hosts = ["app"], keeps <plugin>/__init__.py import-only (there is no QGIS to call a factory), and writes <plugin>/__main__.py as the entry. A web page still needs --host desktop: the page is placed through the Desktop adapter’s iface, which the app host does not provide - declare both hosts and keep the page for the Desktop one (decision-52 point 3 keeps the bridge, not the placement).

gui=True gives the app a window; the bridge does not depend on that window. A page reaches runtime.actions through the same host services, the same grants and the same WebChannel transport as in QGIS Desktop, because the app host wraps the same PluginRuntime in the same SurfaceHost - what an app lacks is the iface, not the contract. The reference example (examples/simple) is one class on both hosts and one set of actions behind its dock page and its native dialogs; its simple/__main__.py is the entry above, and the pure test test_reference_example.py::test_the_example_runs_headless_as_an_app_on_the_same_actions runs it headless through FakeAppBackend and reads back the same action ids.

qgis_sdk.testing.FakeAppBackend drives the real AppSession with no Qt at all: it records init/build/exec/process_events/quit/exit, builds a PluginContext whose ctx.host is app, hands the session a FakeWorkers pool and a fake UI, and lets a test watch the shutdown report.

from qgis_sdk.app import run
from qgis_sdk.testing import FakeAppBackend
backend = FakeAppBackend()
assert run(app, backend=backend, once=True) == 0
assert backend.calls == ["init", "build", "process_events", "exit"]
assert backend.surface.context.host.value == "app"

The window, the canvas and the real bindings need a toolkit: the qgis layer’s test_app_native.py starts the host in a child interpreter with QT_QPA_PLATFORM=offscreen and asserts the QMainWindow, its canvas, the toolbar action and the dialog the plugin opened.