Skip to content

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 on QGIS_PLUGINPATH: <root>/.qgis-sdk/dev/host/<plugin>/ holds the rendered metadata.txt and an __init__.py that extends its __path__ to the source package, runs the author’s __init__.py and binds classFactory = 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: a pyproject.toml change means restarting dev. The SDK’s own packages join that process’s PYTHONPATH, 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 *.py changes (polling every 500 ms; web/ and caches excluded - the page is Vite’s business) and reports to the terminal. --no-watch leaves reloads to the button.

    Every reload, however triggered, follows one policy:

    1. Preflight without touching the running revision: every module of the package compiles and check_project finds nothing QGIS would refuse (SYNTAX* errors inside the package; PROJECT*, CONFIG*, META*, GRANT*, VERSION* errors on pyproject.toml); QGIS001/ARCH001 only warn, findings in tests/ never block. A problem is refused - reload refused, revision N keeps running: ... - and nothing is unloaded.
    2. 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 restarting qgis-sdk dev, is the way out. No thread is killed, nothing unsaved is discarded.
    3. 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).

  • standalone runs the host package’s classFactory(None) + initGui() - host() with no iface - in a bare QgsApplication under the QGIS Python. It is the documented reduced mode: no iface, no plugin lifecycle hooks beyond the two calls, no reload; close the window to stop.

  • server puts a real QgsServer in 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’s serverClassFactory, and requests go through QgsServer.handleRequest with a buffered request and response, which is the C++ path qgis_mapserv.fcgi drives. --port is 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 WMS
    curl '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 MAP is served with an empty project. QgsServer dispatches a SERVICE only when it has a project - with none it answers Project file error. For OWS services: please provide a SERVICE and a MAP parameter... - and a @server.service hook that answers its own SERVICE has no file to name, which is what makes the loop useful before a project exists. A request that does name MAP is QGIS’s to load, exactly as under FCGI. The page of a web/ 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 serverClassFactory and 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 export HOOKS = ServerHooks((...)) - the name qgis-sdk new --host server writes - 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 new qgis-sdk dev. A reload that fails leaves the endpoint answering 503 with 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 as QGIS_SDK_DEV_SERVER. The runtime’s view (qgis_sdk.adapters.webview, opened by host()) reads that variable, loads the page from the origin and grants it in BrowserPolicy(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 and bun run build never references the server. --no-web skips 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.