Desktop bindings and native surfaces
A plugin reaches QGIS Desktop through bindings and registered native surfaces.
A binding places an action or surface on the toolbar, under a menu or behind a
shortcut; a surface is a Dialog or Dock subclass. @app.surface registers
and returns the original class, so it stacks with bindings in either order.
For a class declared in another module, app.add_surface(TheSurface) also works.
from qgis_sdk import QgisSdkPlugin, menu, shortcut, toolbarfrom qgis_sdk.core import PluginContextfrom qgis_sdk.ui import Button, Dialog, Dock, on
app = QgisSdkPlugin(id="field_tools", title="Field tools")
@app.surface@menu("Plugins/Field tools")class BufferDialog(Dialog): title = "Buffer a layer"
def build(self): return Button("buffer", "Buffer 25 m")
@on("buffer").clicked def buffer(self) -> None: self.ctx.actions.buffer(layer_id=self.initial["layer_id"], distance=25)
@app.surfaceclass LayerPanel(Dock, area="left"): def build(self): ...
@toolbar(icon="buffer.svg", text="Buffer")@shortcut("Ctrl+Alt+B")@app.action(id="open_buffer", title="Open buffer")def open_buffer(ctx: PluginContext) -> None: ctx.ui.open(BufferDialog, initial={"layer_id": ctx.project.layer_ids()[0]})
@app.action(id="buffer", title="Buffer")def buffer(ctx: PluginContext, layer_id: str, distance: float) -> str: ...@toolbar, @menu and @shortcut stack with @app.action in either order
or apply to a surface class. describe_bindings(app) and
describe(app).bindings report placements and registered surfaces; an action’s
own describe() reports its stacked bindings. Bindings are not serialized into
the bridge contract. Icon paths are relative to the package’s icons/ directory.
The trigger rule. A placed action for a surface opens it. A placed
action for an action calls the handler with no arguments; an action that
takes inputs cannot be run from a button, so qgis-sdk check reports it as
UI003 and the Desktop warns in the message bar instead of guessing. Give it
a surface that collects the inputs and calls self.ctx.actions.<id>(...), as
BufferDialog does above. A handler that raises a QgisSdkError is reported
in the bar and re-raised; one that returns a JobHandle (an action that
submits a job and returns its handle) gets a progress widget in the bar and a
“finished”/“failed” message when the job settles - the handler itself stays on
the GUI thread, so offload the work with a @job and return the handle rather
than blocking the button.
Lifetimes. A Dialog is a fresh instance per opening: the host
constructs it, binds self.ctx (a UiContext) and self.initial on the
instance, calls build(), shows the window parented to the QGIS main window,
and on close calls on_close(), closes the view’s scope (jobs, timers and
network it owned) and deletes the widget. A Dock is one instance per
running plugin, created lazily, placed with iface.addDockWidget in its
area; its binding is a checkable toggle, its visibility is written to
ctx.settings under ui.<surface id>.visible and restored on the next start,
and unload removes it. self.ctx.view.request_close() closes a dialog or
hides a dock.
Surface-first: services without actions. A plugin can consist entirely of
registered surfaces; an action is not needed just to open a dialog or use an
SDK service. A view’s UiContext exposes algorithms, styles, desktop
and ui when its host supplies them, in addition to layers, project,
files, settings, network and log. ctx.ui.open(...) opens another
registered surface, and ctx.desktop.iface is the native QGIS escape hatch,
not a portable or sandboxed interface. For example:
from qgis_sdk import QgisSdkPlugin, menufrom qgis_sdk.ui import Button, Dialog, Text, on
app = QgisSdkPlugin(id="field_tools", title="Field tools")
@app.surfaceclass Details(Dialog): algorithm = Text()
@app.surface@menu("Plugins/Field tools")class Tools(Dialog): inspect = Button(text="Inspect buffer")
@on(inspect).clicked def show_details(self) -> None: algorithm = self.ctx.algorithms.describe("native:buffer") self.ctx.ui.open(Details, initial={"algorithm": algorithm.id})The Desktop host shares the plugin’s service instances with each view, not
copies. ctx.has(name) probes availability; reading an absent capability raises
QgisSdkUnsupportedCapabilityError and reading through a closed view context
raises QgisSdkScopeClosedError. Services retained before the view closes
still have their own plugin-level lifetime: do not use a retained service or
schedule callbacks on behalf of a closed view. Opening a second dialog creates
its own view scope, which does not close just because the first dialog closes.
Surface handlers and direct algorithm calls are synchronous, usually on the GUI
thread; UiContext does not automatically offload work. Provider registration
and worker ownership are not exposed by this extension. fake_ui_context
accepts the same capabilities as keyword arguments for pure surface tests.
ctx.ui.open and UiContext.actions. From an action,
ctx.ui.open(Surface, initial={...}) opens a registered surface (an
unlisted class is refused by name); outside QGIS Desktop the ui service is
absent and ctx.ui raises QgisSdkUnsupportedCapabilityError naming it.
Inside a surface, self.ctx.actions.<id>(**values) (or
self.ctx.actions.call("<id>", **values)) dispatches the plugin’s own action
through the same validated path a bridge call takes - unknown ids and fields
refused by name, values coerced with the form rules - with the view’s
authority: while that action still runs from this view (a plain handler, or
a job whose handle has not settled) a second call from the same view is
refused, so a double-click cannot submit twice. ctx.actions.ids and
ctx.actions.running("<id>") are the introspection.
Testing without QGIS. qgis_sdk.testing.FakeUi(scope, app=app) stands in for the service: ctx.ui.open(...) records an
OpenedSurface (surface, initial, id) instead of building anything,
and FakeViewControl records request_close. qgis-sdk new --ui native
writes this shape with a passing starter test; qgis-sdk check adds UI002,
UI003, UI004 and ICON001 (a @toolbar icon that is not a file under
<package>/icons/). build() may return plain PyQt, one catalogue component
or nothing at all when ui = "file.ui" names a Designer form; Wiring,
Containers, feedback, buttons and Custom and Testing surfaces without
QGIS below are the rest of the picture.
Input components
Section titled “Input components”qgis_sdk.ui ships the eleven input components of the catalogue
(decision-55 point 3). Each is state first, widget second: it holds a
model value without Qt - a layer id, a field name, a CRS authority id, a
FileRef, a number, a str, a bool, a colour string - and mount()
builds the QGIS widget that mirrors it. Importing qgis_sdk.ui loads no
Qt; the Qt half lives in qgis_sdk.adapters.qgis.components and is looked
up by name when a component is mounted.
| Component | Value | Native widget |
|---|---|---|
LayerPicker(name, kind="vector"|"raster"|"any") |
layer id or None |
QgsMapLayerComboBox (follows the project) |
FieldPicker(name, of=<LayerPicker>) |
field name or None |
QgsFieldComboBox (follows of) |
CrsPicker(name) |
"EPSG:4326" or None |
QgsProjectionSelectionWidget |
FileInput(name, filters=("*.csv",)) |
file FileRef or None |
QgsFileWidget |
DirectoryInput(name) |
directory FileRef or None |
QgsFileWidget (directory mode) |
Distance(name) |
float metres, 0 or more |
QDoubleSpinBox with m |
Number(name, range=(low, high), step=, integer=) |
int for an integral range, else float |
QSpinBox/QDoubleSpinBox |
Text(name) |
str |
QLineEdit |
Toggle(name) |
bool |
QCheckBox |
Choice(name, options=(...)) |
one of the options (the first by default) | QComboBox |
Colour(name, alpha=True) |
#rrggbbaa (#rrggbb when alpha=False) |
QgsColorButton |
Every component has name (the handler’s parameter name), label,
object_name (form/<name>, set on the widget so a test or an agent
selects it by path), value(), set_value(value), changed(listener)
returning the unsubscribe, refresh(), snapshot() (the semantic node of
decision-51: type, name, label, objectName, kind, value and the
component’s details such as minimum/maximum, options, filters, of)
and spec (the FieldSpec it validates with). set_value refuses what a
declared form refuses, with the same words (QgisSdkFormError from
qgis_sdk.ui.coerce_value), and a refused value changes nothing. Pickers
add options() -> (Option(id, label), ...); a LayerPicker refuses an id
the project does not hold and a FieldPicker a name its layer lacks.
Attach, then mount. component.attach(self.ctx) binds it to the
surface’s UiContext (once; a second context is refused): pickers list from
ctx.project/ctx.layers, FileInput/DirectoryInput mint through
ctx.files, and when the context’s scope closes every listener is dropped
and the widget disconnected. mount(parent=None) builds the widget and
returns it (.widget afterwards; idempotent); without QGIS it refuses by
name. When build() returns a component the host attaches and mounts it
for you:
from qgis_sdk.ui import Dialog, LayerPicker
class PickLayer(Dialog): def build(self): self.layer = LayerPicker("layer") self.layer.changed(lambda layer_id: self.ctx.notifications.info(str(layer_id))) return self.layerInput is the base of the eleven; Component above it is what Button,
Column and Form share (attach reaches every child, dispose disposes
them). Every input accepts required=; Form sets it from the schema. To
compose components, return a Column(name, [form, button]) from build()
or mount(parent) each into a layout of your own.
Files. FileInput.set_path(path) / DirectoryInput.set_path(path) mint
a FileRef through ctx.files and make it the value - this is what the
native file widget does when the user picks - so a handler receives a ref,
never a path (decision-20), and ctx.files.resolve(ref) is how it reaches
the file (see ctx.files below). set_value(ref) accepts a ref ctx.files
can resolve and refuses a stranger; path() is the resolved path of the
current ref.
Under the fake. A pure test attaches a component to a UiContext built
over FakeLayers/FakeProject/FakeFiles and never mounts it; the same
contract tests the runtime runs natively (tests/ui_component_contract.py)
pass against that state. LayerPicker.refresh() re-reads the project under
the fake (natively QgsMapLayerComboBox follows the project on its own);
kind="raster" lists nothing under the fake until the all-layer-types
service lands. Note the names FileInput, DirectoryInput and
Colour exist twice: as parameter markers in qgis_sdk.models (what a
handler’s signature declares) and as components in qgis_sdk.ui (what a
surface shows); import the one you mean, or alias.
Wiring: @on, @subscribe, ctx.on
Section titled “Wiring: @on, @subscribe, ctx.on”A surface’s methods are connected to its widgets after build() and
disconnected when the window goes, by the host under QGIS and by
build_surface under the fake (decision-55 point 2). Nothing is connected
by hand and nothing is wrapped: the decorators mark the method and add describe(), so a
test calls it directly.
from qgis_sdk.ui import Column, Dialog, Distance, LogView, RunButton, StreamEvent, on, subscribe
class Buffer(Dialog): def build(self): return Column("buffer", [Distance("distance"), RunButton("run", "buffer"), LogView("log")])
@on("distance").changed # the component's listener; the value is the argument def distance_changed(self, metres: float) -> None: ...
@on("run").finished # what the action returned, or its job handle def ran(self, result) -> None: ...
@on("distance", "editingFinished") # any Qt signal of the widget behind the name def committed(self) -> None: ...
@subscribe("progress", layer="roads") # a plugin @stream, with its request def progress(self, event: StreamEvent) -> None: ...@on(name).<signal> names a component or widget by the name it was
built with ("distance", which is form/distance on the Qt side; a bare
objectName of a hand-built or Designer widget also works) and one of the
catalogue’s listener names: changed, clicked, toggled, edited,
selected, accepted, cancelled, finished. On a component the
listener is used (Component.changed(...), RunButton.finished(...)); on
a plain widget the matching Qt signal is (valueChanged, textChanged,
toggled, clicked, …). An unknown listener name is an AttributeError
at class creation. @on(name, "signal") connects a Qt signal by its
own name. Both pass as many positional arguments as the method declares,
so def go(self) on a clicked(bool) signal is fine. A name build() did
not produce, or a widget without that signal, is refused when the surface
opens, naming both; qgis-sdk check reports the Designer-file case
statically as UI002.
@subscribe(stream, **request) subscribes the method to one of the
application’s @app.stream functions for the view’s lifetime with the request
validated the way a page’s subscribe is (unknown or ill-typed arguments
refused by name). The method receives a StreamEvent(stream, kind, payload, echo, terminal): kind is the declared event kind, "complete"
or "error"; echo is True when the event was caused by this view’s own
ctx.actions call (a synchronous push during the call - a pull iterator
never echoes), so a panel can ignore what it just did. Events reach the
method on the GUI thread only: a push from another thread waits for the
host’s pump (every 50 ms while something is pending; nothing runs once the
view’s scope closed). ctx.on(stream, callback, **request) is the
imperative form and returns the ViewSubscription (open, pending,
dispose()); ctx.streams.open lists the view’s live subscriptions. Closing the window closes every subscription and asks a
PushSource to close().
Hooks. on_accept() is what the Accept button of a HasAcceptCancel
surface runs (returning False keeps the window open); on_close() runs
once the window closes, before the view’s scope disposes.
The declarative form: components as class attributes
Section titled “The declarative form: components as class attributes”A surface whose tree does not depend on ctx can skip build() and the
names altogether. A component assigned to a class attribute takes the
attribute’s name; the default build() returns the declared components no
other declared component contains (one root as it is, several stacked in a
Column named after the class); @on accepts the component itself:
from qgis_sdk.ui import Button, Dock, Group, LogView, ResultTable, Row, RunButton, on
class ResultsDock(Dock, area="right"): fixture = RunButton(action_id="open_fixture", text="Open the fixture") # buttons/fixture clear = Button(text="Clear") # buttons/clear tools = Row([fixture, clear]) # tools results = ResultTable(columns=[("name", "Layer"), ("crs", "CRS")]) # feedback/results log = LogView() # feedback/log log_group = Group([log], label="Log", collapsible=True) # log_group
@on(fixture).finished def opened(self, layer) -> None: self.results.set_rows([*self.results.rows(), {"name": layer.name, "crs": layer.crs}])
@on(clear).clicked def cleared(self) -> None: self.results.set_rows([])The class attributes are prototypes. Every opening gets its own deep copy
of the whole set before build() - the host and build_surface both do
this - so self.results in a method is that window’s table, two docks never
share a listener or a widget, and a RunButton(progress=progress) on the
instance points at the instance’s JobProgress. Shared state belongs on
the plugin, not on a component. A Custom declared this way needs a
factory (a widget instance cannot be copied; the refusal names the
attribute). Every catalogue class takes its arguments by keyword for this
(Row([a, b]), Group([...], label=), RunButton(action_id=),
Button(text=), ResultTable(columns=), Custom(widget=)); the
positional Name("name", ...) forms are unchanged, and an explicit name
wins over the attribute’s. A component without a name refuses to attach or
snapshot and says what to do.
A Form takes declared inputs too: Form([value, note], action_id="ping")
in the class body keys its fields by name the first time anything asks
(fields, values(), attach) - by then the inputs have their attribute
names - and refuses two of one name then. The form’s copy holds the
window’s copies of the inputs, and a field’s change reaches the window’s
form, not the prototype. Form.for_action still needs ctx and so a
build(); Form.from_schema(schema) can be declared.
initial reaches the declared inputs by name: the default build() sets
every declared Input (at any depth - inside a declared Form, Group or
Row too) whose name is a key of self.initial, and refuses a key no
declared input carries (PingDialog has no input 'typo'; it has: ...), the
way Form.for_action(initial=) does. An author’s build() reads
self.initial as it pleases, as before.
A snapshot node of a declared component carries "attribute", the class
attribute it came from, next to "name" - the two differ only when the
name was given explicitly (explicit = Button("named")) - which is what an
agent that reads the snapshot back can address in the source.
@on(component) on a component the class does not declare is refused when
the surface opens unless build() placed that very object in the tree.
qgis-sdk check reads the declarative form as far as the source shows it
(UI005): an @on(attribute) whose attribute is a string or a number, or a
bare name the class never binds, and a Custom declared with a widget
built at class definition (Custom(widget=QLabel(...))); a call it cannot
see into (helper = make_button()) and a module-level name are left to the
host, and a module-level NAME = "..." constant resolves for UI002.
Mixing is fine: an author’s build() sees the copies
(Column("root", [self.note, ...])), and BufferDialog in the example
keeps build() because Form.for_action needs ctx, while ResultsDock
and the scaffold’s PingDialog are declared.
Containers, feedback, buttons and Custom
Section titled “Containers, feedback, buttons and Custom”The rest of the catalogue (decision-55 point 3), each a Component with a
state and a semantic snapshot(), mounted as QGIS widgets by the host:
| Component | Holds | Native widget / objectName |
|---|---|---|
Row(name, children) / Column(name, children) |
children | QWidget with QHBoxLayout/QVBoxLayout, objectName = name |
Panel(name, children, scrollable=False) |
children | QWidget, or a QScrollArea around it |
Group(name, children, label=, collapsible=False) |
children | QGroupBox / QgsCollapsibleGroupBox |
Tabs(name, children) |
one child per tab, each child’s label the title; current()/set_current(name)/changed |
QTabWidget |
Button(name, text) |
clicked(listener), click() |
QPushButton, buttons/<name> |
RunButton(name, action_id, values=, progress=) |
runs the action through ctx.actions with values (a Form, a mapping or a callable); last, finished(listener) |
QPushButton, buttons/<name> |
AcceptCancel(name="dialog_buttons") |
accepted(listener), cancelled(listener), accept(), cancel() |
QDialogButtonBox, buttons/<name> |
JobProgress(name) |
bind(handle) follows a job handle: status, progress, message; the Cancel button cancels it |
progress bar + label + button, feedback/<name> |
LogView(name, limit=500) |
the plugin’s ctx.log records as they arrive: records(), lines(), clear() (refused by name without ctx.log) |
QPlainTextEdit, feedback/<name> |
ResultTable(name, columns) |
rows of mappings under columns (keys or (key, heading)): set_rows, rows(), cells(), select(i), selected(), selected_row(), changed |
QTableWidget, feedback/<name> |
Custom(name, widget_or_factory) |
any QWidget you built, or a zero-argument factory called at mount |
the widget itself, custom/<name> |
Container.find(name) reaches a descendant by name; attach(ctx) and
dispose() walk the children. ui_snapshot(widget_or_component) (decision-51)
lists the semantic nodes of a mounted tree or of an unmounted component tree,
so an agent and a pure test read the same structure.
The widget form. A plain class - typically a QWidget subclass you
already have - with kind = "dialog" or kind = "dock" and a constructor
taking the UiContext is accepted in surfaces as it is; the host shows the
instance, binds initial when it can and calls on_close() if present.
It opens only under a host (the class is Qt), so keep it in a module
declared native and reach it from tests through the desktop harness.
Behaviour mixins
Section titled “Behaviour mixins”Three mixins add the common behaviours to a Dialog/Dock (decision-55
point 5); list them before the base class:
from qgis_sdk.ui import AcceptCancel, Column, Dialog, HasAcceptCancel, JobProgress, RunsJobs
class Export(RunsJobs, HasAcceptCancel, Dialog): def build(self): self.progress = JobProgress("job") return Column("export", [..., self.progress, AcceptCancel()])
def on_accept(self): self.run("export_layer", layer_id=..., file_name="out.gpkg") # a job: tracked, shown, cancelled on close return False # keep the window open while it runsRunsJobs:self.run(action_id, **values)calls the action throughctx.actions; a returned job handle is tracked (self.jobs), bound to theJobProgressstored asself.progress(progress_attributerenames it) and cancelled when the window closes;cancel_jobs()does it early.SubscribesStreams:self.subscribe(stream, callback, **request)andself.subscriptions/unsubscribe_all()overctx.on.HasAcceptCancel: wires theAcceptCancelrow namedbuttons("dialog_buttons"by default; aQDialogButtonBoxof thatobjectNamein a hand-built or Designer tree works too): Accept runson_accept()and closes unless it returnedFalse; Cancel runson_cancel()and closes.
Designer files and the pure-PyQt path
Section titled “Designer files and the pure-PyQt path”ui = "buffer.ui" on a surface names a Qt Designer file relative to the
class’s module; the default build() loads it with uic under the host,
and @on names are the file’s objectNames. Under the fake,
build_surface reads the same XML into a FakeUiTree of FakeWidgets
whose signals (clicked, valueChanged, … by widget class) a test
emits. qgis_sdk.ui.load_ui(cls) is the loader when a build() wants to
add to the loaded form. The pure-PyQt path stays: build() may construct
Qt widgets itself (import Qt inside build()), name the interactive ones
(UI001) and still use @on(name, "signal") and the mixins over them -
the scaffold’s PingDialog is written that way.
Testing surfaces without QGIS: build_surface
Section titled “Testing surfaces without QGIS: build_surface”qgis_sdk.testing.build_surface(Surface, ctx, initial=) runs build()
exactly as the host would, minus Qt: the instance gets ctx and initial,
the returned component is attached (never mounted), a ui = surface gets
the fake tree, and the @on/@subscribe methods and the mixins are wired.
It returns a BuiltSurface (surface, root, ctx, find(name),
close()); fake_ui_context(scope, runtime=started_runtime, **services)
builds the UiContext over RecordingNotifications, FakeViewControl,
RecordingLog and - given the runtime - real ViewActions/ViewStreams,
so a button click under the fake dispatches the plugin’s own handler and a
@subscribe receives what the plugin’s stream pushes. The example’s
tests/test_workflow_pure.py drives BufferDialog and ResultsDock this
way; its test_workflow_qgis.py opens them under the host and checks the
same names on the Qt tree.
Snapshots: the tree, the PNG and the tools
Section titled “Snapshots: the tree, the PNG and the tools”SurfaceHost.snapshot(surface_id=None, *, screenshot=True) captures the
surfaces this plugin’s host opened: a dialog it showed, a dock it built and
left visible. That is the whole scope rule (decision-51 point 6) - there is no
tree-walk of the QGIS main window, so a capture can never hold another plugin’s
window. An unknown id is refused with the ids that are open.
Each capture is canonical JSON (sorted keys, no spaces, one trailing newline)
plus a PNG taken with QWidget.grab() under the offscreen QPA as well, so two
captures of an unchanged surface are byte-identical:
{ "surface": "buffer_dialog", "kind": "dialog", "title": "Buffer", "size": {"width": 420, "height": 180}, "scale": 1.0, "screenshot": "sha256:...", "nodes": [ {"id": "form/distance", "role": "spinbox", "type": "QgsDoubleSpinBox", "value": 25.0, "label": "Distance", "state": {"enabled": true, "visible": true, "focus": false}, "geometry": {"x": 8, "y": 8, "width": 200, "height": 24}, "declared": {"field": "distance"}} ], "findings": []}role is the semantic node - textbox, combobox, spinbox, checkbox,
button, label, group, map, crs, colour, file, slider, … - and
sits in front of the Qt class in type, so a snapshot reads as the catalogue
component that made it rather than as whatever widget the adapter chose.
value is the role’s own value (a spin box’s number, a combo box’s current
text, a button’s text), label comes from QLabel.buddy() and the form’s rows,
geometry is in surface coordinates, and declared links form/<field> back
to the field and buttons/<name> to the button. An actionable widget with no
objectName gets a positional id (#0/1) and a UI001 finding, which is the
same rule qgis-sdk check reports before the plugin ever runs.
Driving a surface from the command line. The surfaces are Qt objects that
only exist where the plugin runs, so each command writes a request into
.qgis-sdk/ui/ and runs the probe in a QGIS interpreter - the CLI process never
touches a widget:
qgis-sdk ui list # the surfaces the plugin declaresqgis-sdk ui open buffer_dialog # build it and report the windowqgis-sdk ui snapshot buffer_dialog # JSON + PNG, --no-screenshot for the tree aloneqgis-sdk ui act buffer_dialog \ --step form/distance:set:25 --step buttons/run:clickqgis-sdk ui diff .qgis-sdk/ui/buffer_dialog.json tests/__snapshots__/buffer_dialog.jsonact sends click, focus, select, set and type through QTest - the
same event path a user’s click takes - and then captures the surface again, so
the second file (<surface>.json, after <surface>-before.json) shows the new
state. It reports the value each widget holds afterwards. diff exits 1 when
anything but the image changed and prints one line per difference. Every
command takes --format yaml for reading; the files on disk are always the
canonical JSON, and YAML lives in the tools, never in the runtime.
act is developer tooling: the runtime a plugin ZIP vendors never mentions
QtTest, and the MCP server marks it DEVELOPER_ONLY. The reading tools -
list, open, snapshot, diff - are what a client gets without that
tooling. Nothing of this reaches the plugin’s HTTP API, which can name only
declared actions and streams.
Approving a surface in a test. The ui_snapshot fixture captures a surface
and compares it with what the project approved under the test module’s
__snapshots__ directory:
def test_buffer_dialog(ui_snapshot, host): host.open(BufferDialog) ui_snapshot(host.snapshot("buffer_dialog")[0]) # fails with the diffPass the SurfaceCapture the host made and the capture stays inside the
plugin’s scope; a widget the test built itself also works, with a name:
ui_snapshot(dialog, name="standalone"). After the test the fixture fails any
surface that changed structurally, and prints where the approval is:
buffer_dialog (tests/__snapshots__/buffer_dialog.json):form/distance: value 0.0 -> 25.0A different PNG does not fail: a screenshot is for a human or an agent to
look at, and a renderer bump is not a regression. To write or refresh an
approval, call approve() on what the fixture returned - it writes
<surface>.json and <surface>.png and the run passes:
def test_buffer_dialog(ui_snapshot, host): host.open(BufferDialog) ui_snapshot(host.snapshot("buffer_dialog")[0]).approve()Commit the two files. A snapshot that has no approval fails with the path it
expected, so a forgotten approve() is visible in CI rather than silent.
qgis_sdk_tools.ui_diff.diff_snapshots(before, after) is the comparison behind
both the fixture and qgis-sdk ui diff, and SnapshotDiff.render() is the text
a failure shows.
ctx.log
Section titled “ctx.log”ctx.log.info|warning|error(message, **fields) is the plugin’s structured
log (decision-63): a LogRecord(level, message, fields, plugin, time) goes to the
QGIS message log under the plugin’s tab on the Desktop (NativeLog) and to
RecordingLog.records/.messages in tests; subscribe(listener) is how
LogView follows it. UiContext carries log as well, so a surface logs
what it did and every LogView of the plugin shows it.
ctx.notifications
Section titled “ctx.notifications”Four levels and one button (decision-55 point 3). info, success,
warning and critical take a message; with_action(label, action_id, **arguments) returns the same service with a button attached, and pressing
it runs that declared action with those arguments through the plugin’s own
runtime.actions:
self.ctx.notifications.with_action("Undo", "undo_buffer", layer_id=output.id).success( f"Buffered into {output.name}")On the Desktop the message lands on the QGIS message bar at its level with a
QPushButton named notifications/<action_id>; headless (NativeNotifications)
it goes to the message log with the label written after the message; under
RecordingNotifications every text is in messages, every
Notification(level, message, action) in records, and press(index=-1)
runs the button once bind_actions(...) named the dispatcher (the Desktop
host binds it at initGui). The wire keeps notifications.info only: a page
draws its own buttons over actions.call, and levelled feedback for pages
arrives with the bridge store slice so HOST_FINGERPRINT does not
move mid-train.
ctx.files
Section titled “ctx.files”ctx.files is the plugin’s door from a FileRef to the filesystem, and the
one place its own code mints refs: grant_file(path, operations=("read",), overwrite=False), grant_directory(path) and resolve(ref, operation="read") -> Path. It is backed by the runtime’s one HostAuthority (decision-20),
the same instance the Processing toolbox mints picker refs through and the
WebEngine host (HostServices.from_context) grants a page’s refs with, so a
ref a handler received from any of them resolves here - and only here: a
forged ref, a ref minted for another view, a read ref asked to write or a
ref whose grant has expired is refused (QgisSdkUnknownHandleError,
QgisSdkGrantRefusedError, …). Closing the plugin’s scope revokes what the
service minted. qgis_sdk.testing.FakeFiles(scope) is the same class over a
private authority (there is no simpler rule to fake); granted lists its
live refs. UiContext carries files, layers and project as well, so a
surface’s components reach them without the plugin context.