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:
- Validate.
check_projectruns first - the same rules and output asqgis-sdk check, so a project without[tool.qgis-sdk] plugin(PROJECT002), with a hand-writtenmetadata.txt(META001) orclassFactory(ENTRY002), or whose installed runtime misses its requirement (VERSION004) is refused - plusWEB001when a web project has no built page. The dependency rules arecheck’s now (decision-50):DEP001for an unknown import root,DEP002forqgis_sdk.testing,DEP003for a compiler-carryingvendordistribution,DEP004for a missing vendored distribution or requirement. Any error refuses the build (exit 1, nothing written). - Web page. With
web/package.jsonthe command runs the project’s ownbuildscript through its lockfile’s manager (bun run build,npm run build,pnpm build) inweb/; Vite writes to<plugin>/web/, which ships. It never installs: missingnode_modulesis exit 2 with the install command to run.--no-webskips the step and ships<plugin>/web/as it is. - Stage and archive. The plugin package is copied without
__pycache__,*.pyc,tests/,.qgis-sdk/,.pytest_cache/,node_modules/(the project’sweb/sources andtests/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 frompyproject.tomlwith every key QGIS requires plushasProcessingProviderfrom the application’s providers,server=Truewhen[tool.qgis-sdk] hostsdeclares 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__.pyfor a project that declaresdesktop,classFactory = host("<spec>", placement=..., grants=...)importinghostfrom the vendored runtime. A server-only project keeps its ownserverClassFactorythere and gets noclassFactory. The plugin application is imported once, in isolation, to read its title and providers (an import error is a plain build error). The runtimeqgis_sdk- every module exceptqgis_sdk.testing- is copied to<plugin>/_vendor/qgis_sdkwith aNOTICE(the requirement the project declared and the runtime version resolved from it, MIT), and every absoluteqgis_sdkimport 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_importsis 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_EPOCHor 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 inAn 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.
CLI runtime
Section titled “CLI runtime”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.