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 runsqgis-minimum-version = "4.0"experimental = true# qgis-prefix = "/path/to/qgis" # where `test`/`dev` find QGIS
[tool.qgis-sdk.metadata] # optional, passed throughhomepage = "https://example.org/field-tools"
[tool.qgis-sdk.web] # optional: the page in web/placement = "dock" # dock | dialoggrants = ["layers.read", "project.layer_ids"] # host grants or operation idsqgis_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.
Which hosts does a plugin declare?
Section titled “Which hosts does a plugin declare?”[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 rundef 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 namesdef demo(ctx: ServerContext, request: ServerRequest) -> ServerResponse: return ServerResponse.xml("<demo/>")
@access_control(priority=20) # asked once per requested layerdef 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 pluginfrom qgis_sdk.adapters.qgis.server import QgsServerHostfrom 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
setServiceExceptionleaves 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.methodis empty on the filter path.QgsServerFilterreceives no request object, andQgsRequestHandlerexposes the URL, the headers and the merged OGC parameters but not the verb. A hook that must tell aGETfrom aPOSTbelongs in a@server.service, where QGIS does hand over theQgsServerRequest.- A refused layer is a refusal, not a filtered map. The access hook answers
Falseand 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 answerscacheKey()with a non-empty string, whichQgsAccessControl::fillCacheKeyreads 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.qgisresolves a name the first time it is asked for, so...adapters.qgis.serverimports on a machine where QGIS is not installed at all. Two things follow: the generated entry can be driven against theqgis.serverstand-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.CANCELLEDimmediately,owner_closedisTrue, 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.pluginsubmits 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.jobsis a capability, so a deployment given no job service answersQgisSdkUnsupportedCapabilityErrornamingjobsinstead 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 |
What the host offers, and what it refuses
Section titled “What the host offers, and what it refuses”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 ifacerun(app, gui=True) # a window: @toolbar/@menu bindings become actions on it, # Dialog and Dock surfaces open inside itctx.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:
- 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;
- running jobs get
graceseconds (15 by default) to settle; whatever is left is named, asked to stop and reported as cancelled, and none of its callbacks run afterwards; - the UI closes, the runtime unloads, and
exitQgis()is called exactly once - including when the start failed halfway, and never wheninitQgis()never ran.
from qgis_sdk.app import AppSessionfrom 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 jobprint(report.to_json()) # the same facts, for a log or a testqgis_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.
Running one from a project
Section titled “Running one from a project”Three routes, one entry:
# 1. inside the project, with whatever interpreter has PyQGISPYTHONPATH=. python -m simple # a windowPYTHONPATH=. 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 itqgis-sdk run # a windowqgis-sdk run --headless --once # headless smoke runqgis-sdk run --grace 5 --title "Field tools"
# 3. a distribution with a console scriptqgis-sdk build --target apppip install dist/simple-0.1.0-appsimple # the same entry, the same flagsqgis-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.
build --target app: what pip installs
Section titled “build --target app: what pip installs”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 andpip 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] vendorare 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.
Scaffolding one
Section titled “Scaffolding one”qgis-sdk new field_tools --host appqgis-sdk new field_tools --host app --ui native # a Dialog on the app windowqgis-sdk new field_tools --host app --algorithm # a Processing providerThe 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).
The bridge is unchanged
Section titled “The bridge is unchanged”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.
Proving one without QGIS
Section titled “Proving one without QGIS”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 runfrom qgis_sdk.testing import FakeAppBackend
backend = FakeAppBackend()assert run(app, backend=backend, once=True) == 0assert 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.