Pure contract layer
The public qgis_sdk.models, qgis_sdk.services, and qgis_sdk.core modules import without QGIS or Qt. Dependencies flow models ← services ← core; the pure layers do not import native adapters, bridge transports, testing fakes, or CLI tools. These are contracts, not a runnable plugin or a replacement for PyQGIS.
| Module | Current contract |
|---|---|
declarations |
QgisSdkPlugin and the shared describe(target) dispatcher; plugin, algorithm, job and provider descriptions satisfy Description (id, title, schema_version, to_dict(), canonical to_json()); decorated targets gain describe() without wrapping |
models |
QgisSdkError, QgisSdkScopeClosedError, QgisSdkUnsupportedCapabilityError, QgisSdkOptionalDependencyError, QgisSdkValidationIssue, and safe QgisSdkContractValidationError metadata; the algorithm requests BufferRequest and CentroidRequest with their refusals QgisSdkAlgorithmRejectedError and QgisSdkAlgorithmCancelledError; the parameter aliases LayerInput, CrsInput, Metres, FileInput(filters=), DirectoryInput(), FieldInput(of=), Range(min, max), Colour() over the markers LayerParameter, CrsParameter, MetresParameter, FileParameter, DirectoryParameter, FieldParameter, RangeParameter, ColourParameter, the registry parameter_kinds()/ParameterKind, and the branded FileRef with its FILE_REF schema |
services |
Structural Notifications, ViewControl, Progress, Cancellation, DesktopAccess, Layers, Project, Settings, Styles, Algorithms, Workers and Processing protocols; implementations are supplied by the host or by tests |
core |
PluginContext, UiContext, AlgorithmContext, JobContext, WorkerContext, Host, ResourceScope, PluginRuntime, and lifecycle errors; a context reports the host that built it as ctx.host (desktop, server, app) instead of inferring one from an iface (decision-52) |
algorithms |
describe_algorithm, invoke and AlgorithmDescription for functions declared with @provider.algorithm, plus parameter_kind - which host parameter an input annotation deserves; input and result schemas come from the same generator the plugin declarations use |
processing |
The generated Processing registry (decision-64): native, qgis, gdal, p3d, pdal modules of TypedDict shapes, request classes and REGISTRY; codegen (the pure emitters), snapshot (core_catalogue()), runs (request coercion and destination allocation); the models (AlgorithmInfo, AlgorithmRun, RunResult, validate_parameters) live in qgis_sdk.models |
jobs |
@job, describe_job, JobService, JobHandle, JobStatus, JobSubscription, Jobs and JobRegistry - scoped local jobs over the step scheduler, named by the plugin’s jobs declaration, with typed handles and owner-bound delivery |
providers |
Provider and @provider.algorithm, ProviderDescription, and declared_providers - providers explicitly added to an application |
ui |
form_fields and coerce_values - the QGIS-free rules that turn a declared action into renderable fields and submitted values back into Python types |
SDK-defined Python exceptions always begin with QgisSdk (including the base and internal subclasses). No unprefixed aliases are exported; their code strings remain independent of class names. Built-in exceptions such as ExceptionGroup retain their standard names.
Plugin authors receive explicit, typed capabilities rather than retrieving services from a global registry. For example, a pure test or runtime can supply a fake notification service:
from qgis_sdk.core import PluginContext, ResourceScopefrom qgis_sdk.models import QgisSdkUnsupportedCapabilityError
class Messages: def info(self, message: str) -> None: print(message)
plugin_scope = ResourceScope() # the runtime owns and eventually closes itctx = PluginContext(notifications=Messages(), scope=plugin_scope)ctx.notifications.info("ready")try: ctx.desktopexcept QgisSdkUnsupportedCapabilityError as error: assert error.code == "unsupported_capability" assert error.capability == "desktop"PluginContext is plugin-lifetime state. Beside notifications and scope it
carries the curated services as capabilities — layers, project,
settings, styles, algorithms, workers, plus desktop and network —
the very Protocols the web-view host surface wraps (decision-27, decision-32),
and processing, the host-side registry the runtime advertises the plugin’s
providers with (decision-42).
A capability is a plain attribute typed as the service: reading one the host
did not supply raises QgisSdkUnsupportedCapabilityError(capability=<name>)
instead of handing back None, and every one of them refuses on a closed
scope (decision-39).
ctx.has("workers") and ctx.capabilities are the probes for code that must
ask rather than use; a typo in has() is a ValueError, not a False. A
handler therefore writes ctx.project.layer_ids() and a page calls
host.project.layerIds(), and when the host built the bridge with
HostServices.from_context(ctx) both read the same object:
from qgis_sdk.bridge.host import HostServices, register_host_operations
services = HostServices.from_context(ctx) # same layers/project/settings/stylesregister_host_operations(dispatcher, services) # instances the plugin's handlers usefrom_context takes the transport under ctx.network (NetworkClient.transport)
for network.fetch unless a network= is passed, and an authority= for file
refs and grants. Inside QGIS, qgis_sdk.adapters.qgis.native_plugin_context(scope, settings_prefix=..., iface=iface) assembles every PyQGIS-backed service into one
context owned by that scope; host()’s default_context calls it whenever a
QgsApplication is running and falls back to a notifications-only context in a
bare interpreter, which is what the scaffold’s starter tests exercise.
Create a child scope for each UiContext; view.request_close() requests closure but does not synchronously dispose the view. Give every AlgorithmContext, JobContext and WorkerContext a fresh child scope plus execution-specific Progress and read-only Cancellation services. Jobs submitted from a view are view-owned by default; plugin-owned work requires an explicit plugin child scope. JobContext also carries the optional workers and network capabilities, refused by name on access when the host lacks them; the job runtime itself lives in qgis_sdk.jobs (see Scoped jobs). A plugin that declares jobs gets ctx.jobs on its own context (plugin lifetime) and a view-scoped copy on each UiContext, both of which refuse by name when the plugin declared none. WorkerContext is different by design: it is what a @job body receives on its worker, so it carries progress and cancellation and refuses every driving-thread capability by name (decision-65).
The runtime owns the scopes: scope.on_close(cleanup) registers synchronous cleanup, scope.child() creates an owned scope, and scope.close() closes children before local callbacks (last registered first). Closing is idempotent. A child closed on its own stops being owned by its parent. New registrations/children after closure raise QgisSdkScopeClosedError (code="scope_closed"); reading a capability such as ctx.desktop also rejects a disposed scope. If cleanup fails, remaining children and callbacks still run and the failures are raised as an ExceptionGroup. Scope closure is not proof that an in-flight task stopped; adapters must request cancellation, detach callbacks and wait for safe completion where needed. The scope does not make services thread-safe or automatically revoke references to them.
Only a Desktop runtime may inject DesktopAccess. Its iface is deliberately typed as opaque object: consumers must opt in via ctx.desktop, and must not serialize it or expose it through a bridge or worker context. Pure/standalone runtimes leave it absent and raise QgisSdkUnsupportedCapabilityError rather than silently fabricating a native capability. Both QgisSdkScopeClosedError and QgisSdkUnsupportedCapabilityError extend QgisSdkError; code is available to downstream adapters, but structured bridge errors are not implemented here.
Pure tests check declared import direction throughout the three layers and import the public modules in a fresh subprocess that rejects native imports. This is not a security sandbox or a full dynamic-import/side-effect analyzer; the CLI static checker and real QGIS adapters are separate backlog work.
The package root exports nothing beyond the declarations API, which is what tests/test_init.py pins:
import qgis_sdk
print(qgis_sdk.__all__)# Includes 'QgisSdkPlugin', 'describe', 'toolbar', 'menu' and 'shortcut'.Importing the package with native modules forbidden is the real wiring check, so a placeholder API is no longer needed to prove it.