Skip to content

Project scaffold

qgis-sdk new <directory> writes a minimal but genuinely loadable QGIS plugin project, built from the public declaration API instead of generated boilerplate. The command is a thin Typer layer in src/qgis_sdk_tools/__main__.py over the public qgis_sdk_tools.scaffold API, so a pip or pixi add --pypi install gets the console script and python -m qgis_sdk_tools reaches the identical entry point. The tools tree is separate from the runtime package qgis_sdk a plugin imports (decision-35): it may use Typer and pytest, the runtime may not, and the runtime never imports it. Typer is a runtime dependency of the wheel for this command alone:

Terminal window
qgis-sdk new /tmp/demo --title "Demo Tools" # installed console script
python -m qgis_sdk_tools new /tmp/demo --title "Demo Tools" # same command via -m
pixi run -e default python -m qgis_sdk_tools new /tmp/demo # from workspace source

scaffold was the command’s name through 0.3.0; it still runs, hidden from --help and marked deprecated, and goes away in a later minor.

ScaffoldOptions.resolve validates everything before a single byte is written: the plugin name must be a lowercase Python identifier that neither QGIS nor the standard library already owns, and the version fields must be dot-separated digits. The result is an in-memory plan written with LF endings, so the same options always produce the same bytes: .gitignore, README.md, pyproject.toml, <plugin>/__init__.py (empty), <plugin>/plugin.py and tests/test_plugin.py. There is no template directory to keep in sync; --dry-run prints the plan without touching disk, and an existing file is refused unless --force is passed.

--host server writes a project for the other entry point. The files are the same six, and two of them differ: [tool.qgis-sdk] hosts = ["server"] says which host loads it, and <plugin>/__init__.py is no longer empty - it holds serverClassFactory, because QGIS Server imports the package and calls that name, so a generated entry has to exist in the source tree (a Desktop classFactory is still appended by build/dev, decision-43). <plugin>/plugin.py declares a @filter, a @service and a @access_control hook instead of actions, and tests/test_plugin.py drives them through qgis_sdk.testing.FakeServerInterface.

The flags that only one host can honour are refused rather than silently dropped: --web, --ui and --algorithm need iface or a Processing registry, and a ServerContext has neither, so qgis-sdk new --host server --web vanilla fails with the reason. A plugin for both hosts is a Desktop project that declares hosts = ["desktop", "server"] and keeps its surfaces for the Desktop one.

--host app writes the third host: [tool.qgis-sdk] hosts = ["app"], the same empty <plugin>/__init__.py (there is no QGIS to call a factory), the same <plugin>/plugin.py, plus <plugin>/__main__.py - the entry python -m <plugin>, 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)

Its starter test drives qgis_sdk.app.run over FakeAppBackend, so the generated suite proves the app host starts, delivers and stops without QGIS or a display. --ui native and --algorithm are allowed on this host - a standalone app with gui=True has a window and a live application - while --web is not: the page is placed through the Desktop adapter’s iface, so a project with a page and an app declares both hosts. See The app host.

There is no metadata.txt and no classFactory in the project (decision-43): pyproject.toml carries [project] (name, version, description, authors, the qgis-sdk-runtime requirement) and [tool.qgis-sdk] (plugin, qgis-minimum-version, experimental); build renders metadata.txt from them and build/dev bind classFactory through host(). <plugin>/plugin.py is one QgisSdkPlugin with two actions receiving a PluginContext, and imports no Qt or QGIS module. tests/test_plugin.py runs host("<plugin>.plugin:app")(None) through start, action and unload without QGIS. Tests pin that the generated project imports nothing native.

The README the scaffold writes teaches both modes; they differ in one thing, whether a host iface exists:

Mode How it runs What the SDK sees
Desktop copy <plugin>/ into a profile’s python/plugins/ and enable it under Plugins, Manage and Install Plugins, or launch qgis --profiles-path <dir> --profile <name> iface is present, so context.desktop succeeds and messages reach the real message bar
Standalone Qt call host("<plugin>.plugin:app")(None) from a plain Qt application or the starter tests, with QT_QPA_PLATFORM=offscreen for a display-free run no iface, so reading context.desktop raises QgisSdkUnsupportedCapabilityError (code="unsupported_capability") instead of inventing a host, and informational messages are dropped

The starter tests are the standalone mode: in the generated project, PYTHONPATH=<this workspace>/packages/qgis-sdk-runtime/src pytest -q runs load, action and unload through the SDK runtime with no QGIS installation at all.

host(...)(None) is the plugin lifecycle without QGIS, which is what a Desktop plugin’s own tests need. A project that declares the app host gets the other half too: qgis_sdk.app.run starts the same declaration with its own QgsApplication, a window when gui=True, and a bounded shutdown - The app host.

qgis-sdk new --algorithm adds <plugin>/algorithms.py - one Provider(id="<plugin>", title=...) and one function algorithm, buffer_layer(ctx: AlgorithmContext, layer_id: LayerInput, distance_metres: Metres, working_crs: CrsInput) -> VectorLayerInfo, the same shape as examples/simple (decision-42) - lists it on the class (providers = (algorithms.provider,)) and exposes the same operation as a buffer action through invoke(algorithms.buffer_layer, ctx, ...). tests/test_plugin.py gains a fourth test that runs the function over FakeLayers/FakeAlgorithms/FakeProject and asserts the exact BufferRequest Processing would have received. build renders hasProcessingProvider=yes from the declaration, and the scaffold Desktop smoke test (pytest -m smoke) scaffolds this variant so real QGIS Desktop proves the provider is in processingRegistry() while the plugin runs and gone after unload. The README gets a “Processing” section naming the toolbox entry <plugin>:buffer. It combines with --web.

qgis-sdk new --ui native adds <plugin>/ui.py with PingDialog, a Dialog subclass bound to Plugins/<Title> in the declarative form: a Number (form/value), a Form([value], action_id="ping"), a Button (buttons/ping) and a LogView over ctx.log as class attributes, no build() and no Qt import anywhere in the package; @on(ping).clicked submits the form and logs the answer. plugin.py lists it on the class (surfaces = (PingDialog,)) and binds a no-input open_ping action to the toolbar that calls ctx.ui.open(PingDialog, initial={"value": 7}), which the default build() seeds into form/value. tests/test_plugin.py gains a test that runs the action under FakeUi and asserts what was opened. The README gets a “Native UI” section naming UI001-UI005 and ICON001, and says in one sentence that a build() returning any QWidget (plain PyQt, imported inside the method) is the escape hatch. It combines with --algorithm and --web; the QGIS_SDK_DEVTOOLS=1 harness scaffolds this variant so qgis-sdk dev inside real QGIS Desktop proves the toolbar action opens the dialog and an edit to ui.py is what the next opening builds after a reload.

new can add a page (decision-18). On a terminal it asks four questions - web UI (none/vanilla/react), placement (dialog/dock), package manager (npm/pnpm/bun), install now (no/yes) - and the answers feed the same ScaffoldOptions.resolve(web=, placement=, package_manager=) the flags do, so a script and a human get one validated plan. Any web flag silences the questions; --no-input or a pipe means no questions and, without flags, no page.

Terminal window
qgis-sdk new field_tools --web react --placement dock --package-manager bun --install
qgis-sdk new field_tools --no-input # a plain plugin, as before

--sdk-dependency SPEC replaces the one value a plugin inside this repository needs changed: what web/package.json depends on for @archont561/qgis-sdk. Without it the page gets the exact bridge release this qgis-sdk-tools was qualified with (SDK_NPM_VERSION in scaffold/web.py, moved by every bridge release through release.py stamp --scope typescript); with it, any npm spec you pass - the example plugin and the workspace harnesses use workspace:* and a file:<tarball> of the checkout’s bridge respectively, never a guessed path (decision-41). Like --placement, it is refused without --web.

What the web plan adds:

  • web/ - a Vite app: package.json (every dependency pinned exactly, @archont561/qgis-sdk at the version this release was qualified with unless --sdk-dependency says otherwise; Vite and Vitest for every manager, never Bunup), vite.config.ts (base: "./" for file: pages, output into <plugin>/web, Chromium target of the oldest supported QGIS), tsconfig.json, index.html (loads Qt’s qwebchannel.js), src/bridge.schema.ts (the schema module rendered from the plugin’s declarations and checked in - <pm> run bindings is the CI drift check), the page (main.ts/app.ts or main.tsx/App.tsx, three statements on QgisSdk.fromBridgeSchema) and a Vitest test against @archont561/qgis-sdk/testing.
  • [tool.qgis-sdk.web] in pyproject.toml - placement and an empty grants list. No Python is added: host() opens the built page (see Desktop host above), and the starter test binds classFactory with the same placement.

Installing is opt-in: --install (or answering yes) runs the manager’s install in web/ after every file is written. A failure keeps the project and prints the retry (cd <target>/web && npm install) with exit code 1; nothing is rolled back and nothing else is ever downloaded. A package named web or tests is refused because the project owns those directories.