Developing against QGIS: qgis-sdk dev
qgis-sdk dev [PATH] [--mode desktop|standalone|server] [--qgis-prefix PATH] [--no-web] [--port N] [--no-watch] runs the plugin from source in a real QGIS
(decision-8, decision-15, decision-21). --host is the same flag as --mode,
because qgis-sdk new --host server and qgis-sdk test --target server name the
same axis; a server project is qgis-sdk dev --host server:
-
desktop (default) starts the located QGIS Desktop binary with
--profiles-path <root>/.qgis-sdk --profile dev, so the profile is the project’s own and survives between runs (.gitignored by the scaffold), and puts the generated host package onQGIS_PLUGINPATH:<root>/.qgis-sdk/dev/host/<plugin>/holds the renderedmetadata.txtand an__init__.pythat extends its__path__to the source package, runs the author’s__init__.pyand bindsclassFactory = host(...)- so QGIS imports the sources in place (no copy, no symlink) and the project tree is never written to. The host package is static for one run: apyproject.tomlchange means restartingdev. The SDK’s own packages join that process’sPYTHONPATH, so the bootstrap (qgis_sdk_tools.devtools.bootstrap, running the policy in.devtools.reloader) imports inside QGIS’s interpreter whichever Python runs the CLI. It enables the plugin, installs a Reload toolbar action (Ctrl+Shift+R), watches<plugin>/for saved*.pychanges (polling every 500 ms;web/and caches excluded - the page is Vite’s business) and reports to the terminal.--no-watchleaves reloads to the button.Every reload, however triggered, follows one policy:
- Preflight without touching the running revision: every module of
the package compiles and
check_projectfinds nothing QGIS would refuse (SYNTAX*errors inside the package;PROJECT*,CONFIG*,META*,GRANT*,VERSION*errors onpyproject.toml);QGIS001/ARCH001only warn, findings intests/never block. A problem is refused -reload refused, revision N keeps running: ...- and nothing is unloaded. - Quiesce through
PluginRuntime.quiesce(): owned jobs are cancelled and given up to 2 s (pumping the event loop between checks) to reach a safe point. A refusal names the blockers, resumes the revision exactly as it was and says that finishing or cancelling the work, or restartingqgis-sdk dev, is the way out. No thread is killed, nothing unsaved is discarded. - Replace: unload (drops every
<plugin>.*module, including those the host package reached through its extended path), then load and start the current source. A failure after teardown cannot be undone: the partially imported modules are dropped, the plugin stays disabled, and the message bar and terminal say so until a Reload succeeds.
Requests are serialised and coalesced: a burst of saves becomes one reload (300 ms debounce), and a request that lands while a reload is in flight is folded into exactly one follow-up. Each attempt carries a generation number in its report. Old-generation work cannot deliver into the new revision because the old scope is closed on unload (subscriptions disposed, late worker reports counted as dropped).
- Preflight without touching the running revision: every module of
the package compiles and
-
standalone runs the host package’s
classFactory(None)+initGui()-host()with noiface- in a bareQgsApplicationunder the QGIS Python. It is the documented reduced mode: noiface, no plugin lifecycle hooks beyond the two calls, no reload; close the window to stop. -
server puts a real
QgsServerin the process and answers HTTP on loopback - no FCGI, no browser, no profile, no Desktop binary (TASK-80 AC 4). The plugin is loaded through the same generated host package’sserverClassFactory, and requests go throughQgsServer.handleRequestwith a buffered request and response, which is the C++ pathqgis_mapserv.fcgidrives.--portis the loopback port (a free one by default) and the command prints the URL:Terminal window qgis-sdk dev --host server# server: devtools_demo answering on http://127.0.0.1:8099# the plugin's own service, then a real project's WMScurl 'http://127.0.0.1:8099/?SERVICE=SDKDEMO'curl 'http://127.0.0.1:8099/?MAP=/path/to/project.qgs&SERVICE=WMS&REQUEST=GetCapabilities'A request that names no
MAPis served with an empty project.QgsServerdispatches aSERVICEonly when it has a project - with none it answersProject file error. For OWS services: please provide a SERVICE and a MAP parameter...- and a@server.servicehook that answers its ownSERVICEhas no file to name, which is what makes the loop useful before a project exists. A request that does nameMAPis QGIS’s to load, exactly as under FCGI. The page of aweb/UI is never served here: a server-only project cannot declare one (HOST001), and the command does not start Vite.Reload is the same policy as Desktop, with one difference QGIS forces: the server interface registers a filter, a service and an access-control object and offers no way to withdraw one, so the first load is the plugin’s own
serverClassFactoryand every reload after it re-imports the sources and hands QGIS’s registration the new hook set (QgsServerHost.rebind). Two consequences are stated rather than discovered: the entry module must exportHOOKS = ServerHooks((...))- the nameqgis-sdk new --host serverwrites - because the factory cannot be called twice, and a change to a filter priority or a service name/version cannot be applied to a live registration, so it needs a newqgis-sdk dev. A reload that fails leaves the endpoint answering503with the reason in the body and on the terminal until the next save succeeds; there is no window in which a half-loaded plugin serves a request, because the loop is single-threaded and reloads between requests.This mode needs a QGIS with server bindings; a QGIS from conda-forge refuses it by name and names the builds that have it (decision-52).
-
web: when the project has
web/with installed dependencies, the command starts the project’s Vite dev server (<manager> run dev --host 127.0.0.1 --port N --strictPort) on loopback, waits for the port, and hands its origin to the QGIS process asQGIS_SDK_DEV_SERVER. The runtime’s view (qgis_sdk.adapters.webview, opened byhost()) reads that variable, loads the page from the origin and grants it inBrowserPolicy(origins=...); the bridge binding carries the same origin, so hot module replacement works and the bridge keeps answering across updates. A built plugin never sees the variable andbun run buildnever references the server.--no-webskips it; a server that does not answer within 60 s stops the command with exit 2.
Every mode exits with the QGIS process’s code; QGIS not found is exit 2 with
the discovery report, a directory without [tool.qgis-sdk] plugin is exit 2
(PROJECT002). qgis-sdk test --target desktop stages the same host
package, and its pytest plugin makes import <plugin> resolve to it inside
the Desktop run, so a desktop test loads the plugin exactly as QGIS does.