Scoped jobs
ctx.jobs is the local job runtime
decision-13
shaped,
decision-38
made function-based,
decision-21
scoped and
decision-65
worker-first: a job is a decorated plain function whose first parameter is the
WorkerContext the runtime injects and whose remaining keyword parameters are
typed the way an action’s are. The plugin names the jobs it exposes, submission
is ctx.jobs.<name>.delay(**inputs) and nothing else, and the handle you get
back is typed end to end. Since 0.9.0 the
whole body runs on a worker: the driving thread submits, validates and
delivers, and never calls your function. qgis_sdk.jobs is QGIS-free; it runs
on the step scheduler and a host drives JobService.tick() from whatever event
loop it already has.
from dataclasses import dataclass
from qgis_sdk import QgisSdkPluginfrom qgis_sdk.core import PluginContext, WorkerContextfrom qgis_sdk.jobs import JobHandle, job
@dataclass(frozen=True)class SumResult: total: int
@job(id="demo.sum", title="Add two numbers")def add(ctx: WorkerContext, left: int, right: int = 0) -> SumResult: # The whole body is on a worker: blocking work is already off the GUI # thread, and there is nothing to await. ctx.progress.report(0.5) return SumResult(left + right)
app = QgisSdkPlugin(id="demo", title="Demo")app.jobs = {"add": add} # what an action reaches as `ctx.jobs.add`
@app.action(id="sum", title="Sum")def sum_action(ctx: PluginContext, left: int, right: int) -> JobHandle: handle = ctx.jobs.add.delay(left=left, right=right) # returns at once ctx.jobs.watch( handle, on_progress=lambda fraction: bar.setValue(int(fraction * 100)), on_success=lambda result: label.setText(str(result.total)), on_failure=lambda error: label.setText(str(error)), on_cancelled=lambda: label.setText("cancelled"), ) return handleThe decorator adds describe() but does not wrap add, so a unit
test calls add(fake_context, left=1, right=2) directly. A bare @job
derives its id and title from the function name. A plain def is the only
accepted form: async def and a generator are refused at bind time, because a
worker runs no event loop. GUI work belongs in a subscriber, which runs on the
driving thread; ctx.jobs.watch(handle, ...) subscribes all four events to the
calling owner’s scope in one call.
How do I name the jobs my plugin exposes?
Section titled “How do I name the jobs my plugin exposes?”A plugin declares the jobs an action may reach, in either of two forms:
app.jobs = {"add": add, "count": count_features} # explicit keysapp.jobs = (add, count_features) # names derived from the functions
@app.job(id="demo.sum", title="Add two numbers") # decorator, same registrydef add(ctx: WorkerContext, left: int, right: int = 0) -> SumResult: ...
@app.job() # name, id and title derived from the functiondef count_features(ctx: WorkerContext, layer_id: str) -> int: ...@app.job(...) is the spelling to reach for when the job is written next to
the plugin: it applies @job itself (so the function describes on its own),
derives the name the way a sequence entry does, and takes key= to rename it
the way a mapping key does. @app.job(id=..., title=...) gives the explicit
form, and @app.job without parentheses works too. All three spellings share
one registry, so a name declared twice - by assignment and by decorator, or by
two decorators - is refused when the plugin is defined.
The second form derives each name from the function’s own name - the same
snake_case rule a bare @job uses for its id, without the module suffix - so
ctx.jobs.count_features.delay(layer_id=...) is what a sequence entry of
def count_features(...) buys you. A @job(id=...) does not change the
derived name: the name is how you reach the job, the id is how it is described.
Use the mapping form when two jobs share a Python name (two modules, one
count_features) or when you want the public name to differ from the
function; a sequence that derives the same name twice is refused at startup
with that instruction. So is an entry that is not a @job function, a key that
is not a plain identifier, a key that shadows what ctx.jobs itself reads
(active, after, delay, bind, describe, faults, names, plugin,
registry, scope, watch, …), and two names for one job id.
Everything is checked when the plugin starts, not when a job is submitted: the
runtime builds the registry once, and ctx.jobs.<name> is a dictionary read
that neither imports nor inspects anything. An unknown name raises
AttributeError naming what the plugin does declare, so a typo never looks
like a job that failed to start.
bind is deliberately not part of this: it stays on qgis_sdk.jobs.JobService
for hosts and tests, and author code reaches a job by the name the plugin
declared. Two limitations come with that, both by design: ctx.jobs.<name> is
a runtime lookup, so an editor cannot autocomplete or type-check it (generated
per-plugin typing is TASK-124), and the web bridge keeps carrying a handle as
{id, status} rather than a live subscription - a page is told the id and the
state, not the progress stream.
What is checked before a job runs?
Section titled “What is checked before a job runs?”describe_job(add) returns the JobDescription (id, title, the parameter
names, which are required, the result type, and the object schema of the
inputs plus the result schema from the declarations generator) without calling
the function; a function without @job, without a WorkerContext first
parameter, with an unannotated parameter or without a declared result is
refused with QgisSdkDeclarationError when the plugin starts (the registry
describes every declared entry once) or when bind is handed the function, and
so is a class (the class form was removed in 0.6.0 - see the migration note
below). An
async def body and a JobContext annotation are refused the same way, each
with the migration it needs. JobDescription.asynchronous stays in the
transport description and is always False since 0.9.0, so a 0.8 reader still
parses one.
Keywords that do not fit the signature - missing, unexpected or of the wrong
shape - are refused by delay with QgisSdkJobRejectedError naming every
problem before anything runs; positional arguments are a TypeError; a
result of the wrong type fails the job, not the tick.
What may a job body touch?
Section titled “What may a job body touch?”The whole body runs on a worker thread (decision-65), so it is handed a
WorkerContext: progress and cancellation, and nothing else. Reading any
other attribute - ctx.layers, ctx.project, ctx.ui, ctx.algorithms -
fails with QgisSdkUnsupportedCapabilityError naming the capability and the
reason (“layers are GUI-affine; pass plain values and re-read them on
delivery”), because a Qt object touched from another thread crashes QGIS
instead of raising. Prepare GUI state before submitting, pass plain values
(ids, paths, numbers) in, and apply GUI changes in the success callback, which
is delivered on the driving thread.
Two guards stand behind that, and neither is a proof of thread safety:
qgis-sdk checkreads the source:JOB001for GUI state reached from a job body (iface, a Qt or QGIS GUI class,QgsProject.instance(), a module-level name holding one of those),JOB002for a context attribute aWorkerContextrefuses, andJOB003as a warning for a by-name import it cannot follow. A name the body rebinds locally - a parameter callediface, an assignment - is not reported. The three rows are in the rule table, which the Project checks section of this guide carries.- the runtime remembers which thread assembled a context. Reading a GUI-bound
capability (
desktop,ui,layers,project,files,streams,actions) from another thread warns once per capability and thread, and raises in a development session (qgis-sdk dev), which is what catches the captured reference -service = ctx.desktoptaken on the driving thread and used inside a job - that a source check cannot see (decision-69).
What is left to you: a helper the body calls, a live object passed in as an argument, and anything a by-name import reaches. Neither guard turns a data race into a reported error, and neither is a sandbox.
Migrating from the 0.5 class form
Section titled “Migrating from the 0.5 class form”Up to qgis-sdk-runtime 0.5 a job was a class with __init__(self, ctx) and
a typed run(self, inputs), submitted as bind(Cls).delay(InputsDataclass(...)).
In 0.6 the same job is the body of run as a module-level function:
@job(id="demo.sum", title="Sum")class Sum: def __init__(self, ctx: JobContext) -> None: self.ctx = ctx
def run(self, inputs: SumInputs) -> SumResult: self.ctx.progress.report(0.5) return SumResult(inputs.a + inputs.b)
handle = ctx.jobs.bind(Sum).delay(SumInputs(1, 2))
# 0.6@job(id="demo.sum", title="Sum")def add(ctx: JobContext, a: int, b: int) -> SumResult: ctx.progress.report(0.5) return SumResult(a + b)
handle = ctx.jobs.bind(add).delay(a=1, b=2)Move self.ctx to the injected ctx argument, spread the inputs dataclass
into keyword parameters (a dataclass is still a fine parameter type when the
inputs are genuinely one object), and call delay with keywords. A class
handed to @job or bind is refused with a message that points here.
Three rules decide everything else
Section titled “Three rules decide everything else”A request is not a state
Section titled “A request is not a state”handle.cancel() returns whether the job was still active and sets
cancel_requested; status becomes cancelled only when the job acknowledges
it (or before the worker ever started it). Nothing interrupts a worker thread:
the body polls ctx.cancellation.is_cancelled(), the request is forwarded to
the backend, and a callable still queued on a real pool reports without
running. A request that arrives before the worker reports settles the attempt
at that report, so a body that runs to the end after being asked to stop ends
cancelled rather than publishing a result nobody is waiting for. Work that
never reports stays running; quiesce() names it as a blocker instead of
pretending it stopped.
Two hosts go further and abandon work instead of waiting for it, and they say so differently because they know different things:
- Unloading the plugin abandons everything through the scheduler’s own rule,
so a job that never started reports the reason it never started
(
QgisSdkScopeClosedError) and a job that had started reportscancelled. - A QGIS Server request has no pump at all, so the request scope’s jobs are
abandoned by name:
statusreadscancelledat once,owner_closedisTrue,result/get()raiseQgisSdkJobCancelledError, and the scheduler stops holding the job. That reading is pinned byJobService.request_scopedand the server host’s per-request context; the plugin’s own work, reached throughctx.jobs.plugin, is untouched.
Callbacks run on the tick, never on a worker
Section titled “Callbacks run on the tick, never on a worker”Progress reported from a worker thread is coalesced to the latest fraction and
delivered on the next tick; each terminal outcome is delivered exactly once, in
subscription order. Subscribing late replays the current state synchronously as
one transition; a terminal replay completes the subscription. A callback that
raises is detached and reported through faults / on_fault, never raised
into the tick. handle.get(timeout) blocks only off the GUI thread - on it,
QgisSdkJobWaitRefusedError; a timeout is QgisSdkJobNotSettledError.
handle.result reads the same outcome without waiting: the typed result, the
job’s own exception for a failed job, QgisSdkJobCancelledError for a
cancelled one, and QgisSdkJobNotSettledError only while the job is queued or
running. A tick() entered while another is on the stack - a job or a callback
opened a modal dialog and its nested event loop fired the host timer - is
ignored and returns 0; a nested quiesce() is refused with the blocker a tick is already in progress on this thread.
Disposal beats delivery
Section titled “Disposal beats delivery”ctx.jobs in a view is plugin_jobs.scoped(view_scope): closing the view
disposes every subscription it owns first, then asks each of its jobs to stop.
Hiding a dock is not disposal. A finished handle whose view closed afterwards
refuses new subscriptions rather than replaying into it. Plugin-lifetime work
is explicit - ctx.jobs.plugin.<name>.delay(...), or jobs.plugin on a view’s
facade - and outlives the view. An action the plugin instance handles submits
the calling view’s work by default: the runtime carries the invoking scope
with the dispatch, so self.ctx.jobs.add.delay(...) inside a view-started
action is that window’s job, and a toolbar or page-level call is the plugin’s. Closing the
plugin scope abandons everything through the scheduler’s existing rule; nobody
is called. A subscription may name another owner (handle.on_success(cb, owner=other_scope)); that owner’s closure detaches it, and the service keeps
one close hook per such owner, released as the job finishes, so a long-lived
owner does not accumulate finished jobs.
There are no automatic retries: resubmission is delay again, with a new
logical id, and attempt is carried as 1 so a later, explicit retry policy
has somewhere to count.
How do hosts compose the service?
Section titled “How do hosts compose the service?”Hosts compose the service once per plugin:
from qgis_sdk.adapters.qgis import QgsJobPump, QgsTaskWorkersfrom qgis_sdk.jobs import JobService
jobs = JobService(plugin_scope, workers=QgsTaskWorkers(plugin_scope), network=network)pump = QgsJobPump(jobs) # a QTimer that calls jobs.tick() on the GUI threadpump.start() # up with the plugin, pump.stop() before it unloadsA PluginRuntime does this itself when the plugin declares jobs: it validates
the registry, builds the service over the plugin scope with the host’s workers,
and adds jobs to the context it was given, so runtime.context.has("jobs") is
true and runtime.context.jobs.<name> is the author’s call. The runtime is the
owner of both: runtime.jobs is the service (what a host ticks) and
runtime.job_registry the validated names. host() in a QGIS Desktop session
starts a QgsJobPump in initGui and stops it before unload, which is why
callbacks arrive without the plugin writing a timer.
workers is not optional any more: a JobService built without one refuses
every submission with QgisSdkJobRejectedError instead of quietly running the
body on the driving thread (decision-65). A job is driven in three moves:
tick() submits, the worker runs the body, tick() delivers.
QgsTaskWorkers is the native backend, and native_plugin_context already
gives you one as ctx.workers. It runs each job body as one QgsTask under
QgsApplication.taskManager(), so the job is a single entry in the QGIS task
bar: the title is the job’s title, its bar follows ctx.progress (the worker
reports, the task bar draws), and pressing cancel files the same cooperative
request handle.cancel() does - the body stops when it next polls
ctx.cancellation, and nothing is interrupted. There are no sub-tasks and no
parent task; a Processing algorithm keeps being the QgsTask Processing
already started. ThreadPoolWorkers stays available for a host without a task
manager (and is what tests/test_jobs_native.py drives); both report through
the scheduler’s Handoff, so nothing about delivery changes between them.
In tests, qgis_sdk.testing.FakeWorkers holds the bodies until the test says
run_one() / run_all() - which is how the suite stages races between
workers, ticks and disposal, and a test passes run_all to a thread of its own
when it wants the body to run off the driving thread - and
qgis_sdk.testing.ThreadWorkers is a real ThreadPoolExecutor for the cases
that want one. tests/features/scoped_jobs.feature states the rules as
scenarios (bound by tests/test_jobs_bdd.py), tests/test_jobs.py holds the
examples, tests/test_jobs_properties.py a Hypothesis state machine over
submit / tick / cancel / worker reports / close view / close listener / close
plugin, tests/test_jobs_native.py the thread evidence over a real pool and a
real Qt event loop, and tests/test_jobs_tasks_native.py the task-manager
evidence (one task per job, its title, mirrored progress, the cancel button).
How do I order one job after another?
Section titled “How do I order one job after another?”ctx.jobs.after(handle) submits work that runs only after the jobs you name
succeeded:
prepare = ctx.jobs.load_layer.delay(layer_id=layer_id)
second = ctx.jobs.after(prepare).run_report.delay(output=path)# the same thing, chained: ctx.jobs.run_report.after(prepare).delay(output=path)The ordering is enforced before the dependent body is handed to a worker, so it
holds for every backend and the task bar never shows work it must not start
yet: the dependent job is queued, then running while it waits, and its body
runs after the prerequisite’s. A prerequisite that fails or is cancelled fails
the dependent job with QgisSdkJobPrerequisiteError naming the prerequisite
and its outcome, and the dependent body never runs. A chain works the same way
- each
aftertakes the handles of what it waits for - and a dependent job can still be cancelled while it waits.
Migrating a 0.8 job to 0.9
Section titled “Migrating a 0.8 job to 0.9”0.8 ran the body on the driving thread and left worker work to the author:
async def plus await ctx.workers.offload(work). 0.9 does the offloading
itself, so a job becomes a plain def and the await goes away. Both old
shapes are refused at bind time with an actionable message; nothing silently
keeps the old execution.
@job(id="demo.count", title="Count")async def count(ctx: JobContext, layer_id: str) -> CountResult: def work() -> int: return len(feature_ids(layer_id))
total = await ctx.workers.offload(work) return CountResult(total)
# 0.9@job(id="demo.count", title="Count")def count(ctx: WorkerContext, layer_id: str) -> CountResult: return CountResult(total=len(feature_ids(layer_id)))What to move:
- The context.
ctx: JobContextbecomesctx: WorkerContext, which carriesprogressandcancellationonly.project,algorithms,layers,iface,network,notifications,ui,view,files,scopeandworkersare gone, and reading one raisesQgisSdkUnsupportedCapabilityErrornaming the capability and why a worker cannot have it. - GUI preparation, before submission. Read the project, resolve a path,
collect plain values and let the user confirm - all on the driving thread,
before
delay. Pass plain values (ids, numbers, dataclasses) into the body, not live layers or widgets. - GUI updates, after delivery. Anything that touches a widget or the
message bar belongs in
on_success/on_failure/on_cancelled, which run on the driving thread. - Long work, in the body. A blocking call that used to need an offload is
now simply called; the whole body is already off the GUI thread. There is no
reason to create a second pool, and
ctx.workersis absent. - What the check now says.
qgis-sdk checkreportsJOB001andJOB002for a body that reaches GUI state or a refused attribute, withJOB003as a warning for a by-name import; the runtime warns once when a GUI-bound capability is read off the thread that assembled the context, and raises in a development session. Fix the body, not the guard. - Cancellation, cooperatively. Poll
ctx.cancellation.is_cancelled()in a loop and stop; the request is a signal, never a killed thread. - A host without a worker backend (a script that only wants to call the
function, a test double) should call the function directly or run the code
without
JobService; submitting is refused rather than silently degrading. - Naming, instead of passing functions. 0.8 submitted the function itself:
ctx.jobs.bind(add).delay(left=1, right=2), orctx.jobs.delay(add, ...)as the one-call spelling. 0.9 has the plugin declare what it exposes (app.jobs = {"add": add}) and the action name it:ctx.jobs.add.delay(left=1, right=2).bindstill exists onqgis_sdk.jobs.JobService, but it is the host’s and the test’s route, not the author’s. - The plugin owner, spelled out.
ctx.jobs.parentis nowctx.jobs.plugin: view code that must outlive the window submits throughctx.jobs.plugin.<name>.delay(...)and the job is the plugin’s, not the view’s. Everything else a view submits closes with the window. - Ordering, if you needed it.
ctx.jobs.after(handle)replaces any hand-rolled “start the second job from the first one’s success callback”: the dependency is declared where the job is submitted, and a failed prerequisite fails the dependent job instead of silently skipping it. - The task bar. On Desktop each job is now one task there. A job whose
title reads well in a user interface is worth naming in
@job(title=...), and a long job should reportctx.progressso its bar moves.