Skip to content

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, ResourceScope
from qgis_sdk.models import QgisSdkUnsupportedCapabilityError
class Messages:
def info(self, message: str) -> None:
print(message)
plugin_scope = ResourceScope() # the runtime owns and eventually closes it
ctx = PluginContext(notifications=Messages(), scope=plugin_scope)
ctx.notifications.info("ready")
try:
ctx.desktop
except 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/styles
register_host_operations(dispatcher, services) # instances the plugin's handlers use

from_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.