0.9.0 migration notes
0.9.0 closes the m-8 milestone: the one plugin class
decision-52
described now runs on three hosts - QGIS Desktop, QGIS Server and a standalone
PyQGIS app - and a job’s whole body now runs on a worker
(decision-65).
It is a Python-only minor release: @archont561/qgis-sdk is not released
with it, because none of it changes the bridge a page talks to
(decision-25).
Nothing a 0.8 project does today stops working, with one deliberate exception: a
job written as async def with await ctx.workers.offload(...) is refused at
bind time with the migration it needs. Everything else is additive - hosts
defaults to ["desktop"], a plugin without server hooks or an app entry never
reaches the new code, and a capability a host does not have still refuses by
name (decision-39).
One plugin class, three declared hosts
Section titled “One plugin class, three declared hosts”[tool.qgis-sdk] hosts says which hosts a plugin runs on: desktop (loaded by
QGIS through classFactory), server (loaded by QGIS Server through
serverClassFactory) and app (started by qgis_sdk.app.run).
[tool.qgis-sdk]plugin = "field_tools.plugin:app"hosts = ["desktop", "app"] # desktop | server | app; default ["desktop"]- The set changes nothing about how the class is written. It changes what
checkreports:HOST001for a Desktop-only declaration in a project that does not declaredesktop,HOST002for a hook imported fromqgis_sdk.serverin a project that does not declareserver, andHOST003(a warning) when the QGIS this interpreter can see was built without QGIS Server. ctx.hostis the host that built the context -Host.DESKTOP,Host.SERVERorHost.APP, compared as strings - and is never inferred from aniface, aQgsApplicationor an environment variable.ctx.host.guiisFalseon a server, which is the honest answer to “may I open a window here?”.- A capability a host does not have refuses by name rather than handing over a
half-built service: the app host has no
ctx.desktop, and the server host has noui,views,workersor algorithm registry. qgis-sdk new --host desktop|server|appwrites the starter for the chosen host and refuses by name what that host cannot honour (--weboutside Desktop,--ui nativeand--algorithmon the server).
To migrate: a Desktop-only project needs no change. A project that gains a
second host adds it to hosts and adds that host’s entry
(serverClassFactory in <plugin>/__init__.py, <plugin>/__main__.py for the
app) - the class itself stays as it is.
A QGIS Server plugin declares hooks, not actions
Section titled “A QGIS Server plugin declares hooks, not actions”A request has no window and no event loop, so a server plugin declares three kinds of hook over the same plain-function shape a job has, and QGIS Server’s own entry imports one factory from the package:
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): return None if request.param("VERSION") else request.with_params(VERSION="1.3.0")
@service(name="SDKDEMO", version="1.0.0") # answers SERVICE=SDKDEMOdef demo(ctx: ServerContext, request: ServerRequest) -> ServerResponse: return ServerResponse.json({"service": "SDKDEMO"})
@access_control(priority=20) # asked once per requested layerdef only_public(ctx: ServerContext, request: ServerRequest, layer: str) -> bool: return layer != "secret"# <plugin>/__init__.py - what QGIS Server imports and callsfrom qgis_sdk.server import ServerHooks
from .plugin import add_version, demo, only_public
HOOKS = ServerHooks((add_version, demo, only_public))
def serverClassFactory(serverIface): from qgis_sdk.adapters.qgis.server import QgsServerHost
return QgsServerHost(HOOKS, serverIface).register()- What a hook is handed. A
ServerContextcarryingproject,layers,logand a request-scopedjobs, plus the plainServerRequestandServerResponsemodels - never aQgsServerInterface, which cannot leave the adapter. Where the deployment loads no project, those capabilities refuse by name. - Order is stated, not sorted. Filters run priority ascending, then the
plugin’s declaration order, then the order of each included plugin
(decision-69);
ServerHooksrefuses at startup what QGIS would refuse at the first request. - A refusal is a 403, not a filtered map. An access-control hook returns
Falseand the host answersServerResponse.deniednaming the layer. - Work dies with the response.
ctx.jobson this host is derived over the request’s scope: a job a hook submits is abandoned when the response is sent, and work that must outlive the request says so withctx.jobs.plugin. - The build has to have it. QGIS Server needs a QGIS built with
WITH_SERVER=ONplus its Python bindings - the Debian/Ubuntuqgis-serverwithpython3-qgis, the official installers, or a source build. conda-forge passes-D WITH_SERVER=FALSE, so a conda QGIS has noqgis.server;checkreportsHOST003there, andqgis-sdk test --target serverrefuses the target by name. QGIS 3.44 LTS is supported on this host alongside 4.x (decision-70). - What you run.
qgis-sdk testproves the hooks againstqgis_sdk.testing.FakeServerInterfacewith no QGIS at all,test --target serverruns the same plugin in-process under a realQgsServer,dev --host serverreloads on save, andbuildwritesmetadata.txtwithserver=Trueand keeps theserverClassFactorythe source already carries (decision-43).
See the server host above for the
full mapping and examples/server for the reference example’s server variant:
the same fixture examples/simple opens through QGIS, answered as JSON over one
OGC service.
A standalone app owns the QgsApplication
Section titled “A standalone app owns the QgsApplication”qgis_sdk.app.run(app, gui=..., argv=..., grace=...) is the host that constructs
QgsApplication instead of being called by one, and qgis_sdk.app.main(app, argv) is the launcher that reads the flags. The class does not change: the same
QgisSdkPlugin, the same actions, the same jobs, the same bridge.
# <plugin>/__main__.py - `python -m <plugin>`, `qgis-sdk run` and a built# distribution's console script all call this.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)has no window and noctx.ui;run(app, gui=True)gives aQMainWindowwith oneQgsMapCanvas, a toolbar and a status bar, and@toolbar/@menubindings andDialog/Docksurfaces attach to it. The GUI flag also decides Qt’sGUIenabled, which is fixed whenQgsApplicationis constructed - hence an argument torun, not a property of the plugin.ctx.hostisHost.APPin both modes andctx.desktoprefuses by name: there is noQgisInterface, and the host does not fake one.- Shutdown is bounded and named.
AppSession.stop()stops every loop the host started (naming the ones that were running), waits up togracefor running jobs, names and cancels the stragglers, unloads the runtime and callsexitQgisexactly once;ShutdownReportcarriesstopped,loops,drained,abandoned,grace_seconds,elapsedandexited(decision-69). qgis-sdk rundiscovers QGIS the waydevdoes and starts the project’s own<plugin>/__main__.pywith the install’s bindings on the child’s path;build --target appproduces an installable distribution whose[project.scripts]launcher probes the declared externals and then callsqgis_sdk.app.main. A wrong interpreter says “this interpreter cannot import PyQGIS” and exits 2 instead of raising from three frames down.
See the app host
above for the flag table, the lifecycle and the build layout;
examples/simple/simple/__main__.py is the reference example’s app entry.
Jobs run whole bodies on workers
Section titled “Jobs run whole bodies on workers”A job is a plain def whose first parameter is the WorkerContext the runtime
injects. Since 0.9.0 the driving thread only validates, submits and delivers: the
whole body runs on a worker, so a blocking call is simply called and there is
nothing to await. async def and a JobContext annotation are refused at bind
time, each naming the migration.
- The worker context is small on purpose.
progressandcancellation; noproject,layers,ui,algorithms,network,filesorworkers. Reading one raisesQgisSdkUnsupportedCapabilityErrorwith the reason (a Qt object touched from another thread crashes QGIS instead of raising). Prepare GUI state beforedelay, pass plain values in, and apply GUI changes inon_success/on_failure/on_cancelled, which arrive on the driving thread. - Names, not function objects. A plugin declares what it exposes -
app.jobs = {"add": add}, a sequence whose names derive from the functions, or@app.job(...)- and an action submits withctx.jobs.add.delay(left=1, right=2). Unknown keywords are refused bydelaybefore anything runs; an unknown name is anAttributeErrornaming what the plugin does declare.ctx.jobs.parentis nowctx.jobs.plugin. - Handles come back, and actions may return one. An action that only starts
work returns the
JobHandleit submitted;ctx.jobs.watch(handle, ...)subscribes all four events to the caller’s scope in one call, andctx.jobs.after(handle)declares ordering. - Two guards, neither a proof of thread safety.
checkreads the source:JOB001for GUI state reached from a body,JOB002for a context attribute aWorkerContextrefuses,JOB003(warning) for a by-name import it cannot follow. At runtime the context remembers which thread assembled it: reading a GUI-bound capability from another thread warns once per capability and thread and raises in a development session.
The 0.8-to-0.9 job migration above is the
step-by-step list, with the before-and-after of an await ctx.workers.offload(...) body.
Each job is one QgsTask on Desktop
Section titled “Each job is one QgsTask on Desktop”On a QGIS Desktop host the worker backend is QgsTaskWorkers: each job body is
one QgsTask under QgsApplication.taskManager(), so the job is a single entry
in the QGIS task bar whose title is the job’s title and whose bar follows
ctx.progress. Pressing cancel files the same cooperative request
handle.cancel() does - the body stops when it next polls ctx.cancellation,
and nothing is interrupted. There are no sub-tasks and no parent task, and a
Processing algorithm keeps being the QgsTask Processing already started. A
host without a task manager keeps ThreadPoolWorkers; delivery does not change
between them.
The bridge does not move
Section titled “The bridge does not move”Hosts and scheduling are host-side: a web view in a standalone app uses the same
@archont561/qgis-sdk release, the same generated catalogue and the same host
manifest as one in QGIS Desktop, and a job handle still reaches a page as
{id, status} - an ID and a state, not a live subscription. No file under
packages/qgis-sdk-typescript changes in this release and no npm version is
consumed with it - qgis-sdk bridge generate renders the same catalogue a
0.8.0 project already ships.
Checklist for a 0.8 project
Section titled “Checklist for a 0.8 project”- Nothing, if it is a Desktop plugin with 0.8-style jobs. Add
hostsonly when the plugin gains a second host. - Rewrite a job that offloads.
async def+await ctx.workers.offload(work)becomes a plaindefwith the work inline andctx: WorkerContext. Both old shapes are refused when the plugin starts, with the message naming this section. - Name the jobs the plugin exposes (
app.jobs,@app.job) and submit withctx.jobs.<name>.delay(...); movectx.jobs.bind(fn)call sites and whatever needs to survive a window toctx.jobs.plugin.<name>. - Run
qgis-sdk check.JOB001/JOB002are errors,JOB003a warning;HOST001/HOST002appear only ifhostsdisagrees with a declaration the project keeps. - Adopting a host:
new --host server|appfor a new project, or add the entry (serverClassFactory,__main__.py) and the host tohostsfor an existing one;buildwritesserver=Trueand--target appwrites the launcher, so no hand-writtenmetadata.txtorclassFactoryappears in either case.