Skip to content

Building an installable ZIP: qgis-sdk build

qgis-sdk build [PATH] [--target plugin|app] [--out DIR] [--no-web] [--json] writes dist/<plugin>-<version>.zip (version from [project] version) for Plugins > Manage and Install Plugins > Install from ZIP (decision-8: the command is argument parsing over qgis_sdk_tools.bundle.build_plugin). --target app writes a pip-installable distribution instead, with a console script that starts the same application as a standalone PyQGIS app - qgis_sdk_tools.bundle.app.build_app and the section The app host above. The ZIP, in order:

  1. Validate. check_project runs first - the same rules and output as qgis-sdk check, so a project without [tool.qgis-sdk] plugin (PROJECT002), with a hand-written metadata.txt (META001) or classFactory (ENTRY002), or whose installed runtime misses its requirement (VERSION004) is refused - plus WEB001 when a web project has no built page. The dependency rules are check’s now (decision-50): DEP001 for an unknown import root, DEP002 for qgis_sdk.testing, DEP003 for a compiler-carrying vendor distribution, DEP004 for a missing vendored distribution or requirement. Any error refuses the build (exit 1, nothing written).
  2. Web page. With web/package.json the command runs the project’s own build script through its lockfile’s manager (bun run build, npm run build, pnpm build) in web/; Vite writes to <plugin>/web/, which ships. It never installs: missing node_modules is exit 2 with the install command to run. --no-web skips the step and ships <plugin>/web/ as it is.
  3. Stage and archive. The plugin package is copied without __pycache__, *.pyc, tests/, .qgis-sdk/, .pytest_cache/, node_modules/ (the project’s web/ sources and tests/ sit outside the package and were never candidates). Into the staged copy only - the source tree is untouched - go the two artifacts QGIS needs (decision-43): metadata.txt, rendered from pyproject.toml with every key QGIS requires plus hasProcessingProvider from the application’s providers, server=True when [tool.qgis-sdk] hosts declares the server (QGIS Server reads that key to decide whether to load the plugin at all; Desktop ignores it) and the [tool.qgis-sdk.metadata] passthrough; and, appended to <plugin>/__init__.py for a project that declares desktop, classFactory = host("<spec>", placement=..., grants=...) importing host from the vendored runtime. A server-only project keeps its own serverClassFactory there and gets no classFactory. The plugin application is imported once, in isolation, to read its title and providers (an import error is a plain build error). The runtime qgis_sdk - every module except qgis_sdk.testing - is copied to <plugin>/_vendor/qgis_sdk with a NOTICE (the requirement the project declared and the runtime version resolved from it, MIT), and every absolute qgis_sdk import in the copy and in the plugin’s own sources is rewritten to <plugin>._vendor.qgis_sdk (decision-35, decision-3: the plugin runs in a QGIS without qgis-sdk and coexists with plugins built against other versions; rewrite_imports is AST-located, touches only import statements and keeps line numbers). The ZIP’s only root entry is <plugin>/; entries are sorted, timestamps fixed (SOURCE_DATE_EPOCH or 1980-01-01), modes normalised, so the same tree gives the same bytes.

BuildResult carries the archive path, plugin, version, file list, the combined CheckReport, the vendored runtime version and the requirement it was resolved from (None under VERSION003), the web outcome (none / built / prebuilt), the declared externals and the vendored distributions; --json prints its to_json().

--target app answers with AppBuildResult instead - the distribution directory, the console script name, the same file/CheckReport/externals/ vendoring fields, and "target": "app" in its JSON - so a script can tell the two targets apart without inspecting the path. The whole app-side contract (build --target app, its pyproject.toml, the generated launcher, the pip install and the installed launcher’s own refusals) is test_app_build.py’s build-install harness; the native row that starts the installed app on a real QGIS is test_app_native.py.

Packages the plugin needs: externals and vendoring

Section titled “Packages the plugin needs: externals and vendoring”

Two exits, both under [tool.qgis-sdk.build] (decision-50):

[tool.qgis-sdk.build]
external = [
"numpy", # every QGIS ships it
{ import = "pandas", requirement = "pandas>=2", reason = "reads spreadsheets" },
]
vendor = ["shapely"] # pure Python, copied in

An external is declared and probed. build renders the declaration into metadata.txt (Requires: pandas (pandas>=2) - reads spreadsheets), so the plugin manager says it before installation. It also emits a classFactory that imports each external before anything of the plugin is imported: when one is missing, the user sees one message-bar error naming the package and the install command for their host - python -m pip install in the OSGeo4W Shell on Windows, /Applications/QGIS.app/Contents/MacOS/bin/pip3 on macOS, python3 -m pip install --user elsewhere - and the plugin stays unloaded instead of failing with a traceback half-way through initGui. The SDK still installs nothing on the user’s machine.

A vendored distribution is copied. vendor names installed distributions; build copies each one under <plugin>/_vendor/, rewrites its imports of the other vendored roots (and the plugin’s own imports of them) to <plugin>._vendor.<root> exactly as it rewrites qgis_sdk, puts its licence in <plugin>/_vendor/LICENSES/<name>-<version>-<file>, and records the name and version in _vendor/NOTICE. The rewrite is a textual, AST-located change to import statements: a package that resolves a dependency by name at runtime (importlib.import_module("numpy")) is out of its reach, so vendor code that does that stays a test-time concern.

pandas, scipy, shapely and their kind are externals with a probe, never vendored: they carry compiled extensions, so build refuses them with DEP003, and a user whose QGIS lacks one has to be told how to install it - which is what the probe does. A vendored package’s own requirements must be vendored or external too; anything else is DEP004 naming the missing distribution, and check reports it before you build.

The runtime is resolved, never installed: build reads the project’s qgis-sdk-runtime (or qgis-sdk) requirement and ships the installed runtime when it satisfies it. A mismatch is VERSION004 and the build is refused with the pip install line to run; no requirement is VERSION003 (a warning) and the installed runtime ships with that fact in the NOTICE.

Commands that read or write anything beyond their arguments receive a qgis_sdk_tools.cli.CliRuntime (cwd, env, out, err) through Typer’s ctx.obj (decision-34). The shell seeds it with CliRuntime.for_process(); a test passes qgis_sdk_tools.testing.FakeCliRuntime(cwd=..., env=...) as CliRunner().invoke(app, args, obj=runtime) and reads runtime.stdout / runtime.stderr back.