Command registry - ULJ-Yale/qunexsdk GitHub Wiki
QuNex discovers its commands through an automatically built command registry. QuNex
scans the source code for specially formatted docstrings and builds a registry
(qx_commands.yaml) that is then consulted on every qunex call to find out which
commands exist, how they are invoked, and how they should be run. This page explains how
the registry works, how to write docstrings so that a command is registered correctly,
how to (re)build the registry, and the different command types (with a focus on the
processing commands).
The registry has a build-time half and a run-time half.
Build time (python/qx_registry_build.py, run through the build_qx_registry
command). QuNex walks the python (.py), MATLAB (.m) and bash (.sh) sources, and for
every function whose docstring contains a .. qx_command: block it extracts a command
record: the command name, any aliases, its type, language, an example call, a short
description, and its parameters and returns. Those records are validated (see
[Building the registry](#Building the registry)) and written to
$QUNEXPATH/qx_commands.yaml. Extensions are handled the same way — each extension under
qx_extensions/qx_<name>/ gets its own qx_commands.yaml.
Run time (python/qx_registry.py). On each qunex call the core qx_commands.yaml
is loaded, any extension registries are discovered and merged into it, and the result is
made available as a queryable registry. Python commands are imported lazily — the code
behind a command is only loaded when the command is actually invoked.
When the core and extension registries are merged, commands are keyed by name, and extension commands take precedence over core commands with the same name. This is intentional: an extension can ship a command with the same name as a core command in order to replace it. The core command is left untouched on disk — the override happens only in the merged, in-memory registry used for that run.
A few rules follow from this:
- Within a single registry (core, or one extension) every command name and every alias must be unique. This is checked when that registry is built, and a clash stops the build.
- Across registries, a shared name is not a conflict — it is an override, resolved in favour of the extension.
- Aliases still have to be unambiguous in the final merged set. If, after overriding, two different commands would lay claim to the same alias, QuNex raises an error when it loads the registry.
Python, MATLAB and bash commands are currently indexed. (R support is planned.)
Writing an extension is covered on its own pages: Writing a QuNex extension for the practical guide, and The QuNex extensions whitepaper for the whole mechanism.
The registry is driven entirely by docstrings, so the docstring format matters: a malformed docstring means the command is either not registered or registered with missing information. A registrable docstring has four parts.
- Call line — the first line consisting of a single ````...```` (double backticks) block. Its contents become the command's example call.
- Description paragraph — the text immediately following the call line, up to the first blank line. It becomes the command's short description.
-
The
.. qx_command:block — a reStructuredText comment block carrying the command metadata. Because it is a reST comment, Sphinx ignores it, so it does not interfere with the rendered documentation. It must providetype:; it may also providealiases:,name:, andlanguage:. -
Parameters:andReturns:sections (optional) — each entry is written as--name (type[, default <value>]):followed by an indented description. The trailing colon on the entry line is required.
def dwi_f99(sinfo, options, overwrite=False, thread=0):
"""
``dwi_f99 [... processing options]``
Runs the FSL F99 registration for a session.
.. qx_command:
type: processing.session
aliases: dwiF99
Parameters:
--sessionsfolder (str, default '.'):
The path to the study/sessions folder.
--overwrite (str, default 'no'):
Whether to overwrite existing results.
Returns:
--log (SessionLog):
The command's log object, carrying its report and its status.
"""Notes for python:
- The parameter order and types come from the function signature; an annotation, if
present, wins over the documented type. Documented parameters that are not in the
signature are attached as
optionsonly if the function has anoptionsargument; otherwise they are ignored (with a build warning). -
name:is optional — the command name defaults to the function name.
The same four-part format applies; only where the docstring lives and how the signature is read differ:
-
MATLAB (
.m): the docstring is the help comment block immediately after thefunctionline; name, arguments and returns are taken from thatfunctionline. -
bash (
.sh): the docstring is the text inside the script'susage()heredoc, and the.. qx_command:block must declarelanguage: bash.
The registry record is not only documentation. It is also the answer to "what parameters may this command be given", and QuNex asks that question on every run: the parameter tiers — the defaults, the batch file's header, a recipe, the command line — are narrowed to what the command declares before the command is called.
Both halves of the declaration count:
def declared_parameters(qx_command):
# the signature arguments, and the options the documentation declares
return {arg.name for arg in qx_command.args} | \
{option.name for option in qx_command.options}For a python command the signature carries most of it. For a MATLAB or bash command there
is no python signature at all, so the documented parameters are the whole interface —
which is why the Parameters: block of a bash usage() heredoc has to be complete and
has to spell each parameter the way the script actually reads it.
Two practical consequences:
-
Declare
batchfileif the command should see the study's parameters. A command that takes--batchfileis filled in from the batch file's header, which is where a study states things once for every command that will be run against it. - Declare what you accept, and no more. A parameter that is neither in the signature nor in the documentation cannot be filled in, and a documented parameter the command never reads is a promise it does not keep.
The tiers, the narrowing and the per-run provenance report are described on the Invocation page.
After adding, removing, or changing a command — including edits to the .. qx_command:
block, the call line, the description, or the Parameters/Returns of an existing command —
rebuild the registry:
qunex build_qx_registryThis requires QUNEXPATH to be set. It rebuilds the core registry at
$QUNEXPATH/qx_commands.yaml (python + MATLAB + bash) and, unless disabled, a
qx_commands.yaml for every extension under the extension search roots. Name
--extensions to limit it to particular ones:
# the core registry, and only the extension called qx_example alongside it
qunex build_qx_registry --extensions=exampleThis is the command for working on QuNex itself. Someone working on an extension wants
build_qx_extensions instead, which builds the
extensions they name and leaves the core registry alone — the core registry is not theirs
to rebuild, and on a shared or containerised installation they cannot write to it anyway.
Both refuse to write a registry to a location that cannot take one, naming the path and,
for the core registry, naming build_qx_extensions as the way on. A registry that is
already current is not rewritten, so rebuilding one that lives somewhere read-only is not
an error — there is simply nothing to do.
Watch the build output. The build validates the records and reports warnings; a command is excluded from the registry when, for example:
- its docstring has no call line or no
.. qx_command:block, - it has no
type:in the qx block, - its name or an alias collides with another command in the same registry (this stops the build).
Commit the regenerated qx_commands.yaml together with the source change.
Every command declares a type: in its qx block. The type serves two purposes: it groups
commands, and — for processing commands — it tells QuNex how the command should be run.
| Type | Purpose |
|---|---|
utility |
General helper/utility commands. A utility command can also ask for a log to report into, by declaring a _log=None parameter — see Logging. |
qa |
Quality-assurance commands. |
matlab |
MATLAB commands. QuNex calls the function by name, passing the documented arguments positionally. |
processing.session |
Processing run per session (see below). Returns a log object. |
processing.subject |
Processing run per subject, across the subject's sessions. Returns a log object. |
processing.study |
Processing run once across all sessions of a study. Returns a log object. |
Most of the heavy lifting in QuNex is done by processing commands, and the processing.*
type is what decides how QuNex hands the data to your function and how it parallelizes the
work. All three processing types share the same function signature, and all three return a
log object:
import qx_utilities.general.log as gl
def my_command(sinfo, options, overwrite=False, thread=0):
# the log this command reports into; it writes its header as it is created
log = gl.SessionLog(sinfo, options, "My pipeline")
...
# the summary, and the log itself, which is what the command returns
return log.finish("My pipeline completed")A processing function returns its log object. general/process.py takes the log,
writes its report into the run's log file with log.write_to(run), and reads
log.status from it — the (session_id, summary, failed) triple that the run's closing
report is built from. finish() records the summary and hands the log back, which is why
return log.finish(...) is the usual last line. A session whose log reports a failure
makes the whole command end with a non-zero exit status. How to build that report is
described on the Logging page.
The important thing to understand is that the three types differ in the granularity at
which your function is called — in other words, what a single call is expected to work on.
QuNex reads the sessions listed in the batch file, and then, depending on the type, it
either calls your function once per session, once per subject, or once for the whole
study. That granularity is reflected in what arrives as sinfo and in which
parallelization applies.
This is the common case: each session is processed on its own, independently of the others. Use it whenever the work for one session does not need to know anything about the other sessions.
Your function is called once per session, and sinfo is that single session (a
dictionary of the session's data), together with options that have been updated for that
particular session. It reports through a SessionLog built for that session, and returns
it. Because the sessions are independent, QuNex can run several of them at
the same time: it processes sessions concurrently across a pool whose size is set by
parsessions. On top of that, a single session often contains several elements to work on
(for example, multiple BOLD runs), and a command may parallelize those internally using
parelements. So you can end up with two levels of parallelism — several sessions at once,
and within each session several elements at once.
Some processing needs to look at all of a subject's sessions together — a typical example
is longitudinal analysis, where sessions acquired at different time points have to be
combined. For this, QuNex first groups the sessions by their subject key (every session
must carry subject information, otherwise the command stops and tells you which sessions are
missing it).
Your function is then called once per subject, and sinfo is that subject's
SessionList — all of the subject's sessions — together with the shared options. It
reports through a SessionLog built for the subject (pass label="Subject" so that the
header names it as one), and returns it. Here
the unit of parallelism is the subject: subjects are processed concurrently across a pool
sized by parsubjects. Since a single call already spans all of a subject's sessions,
parsessions no longer applies and QuNex forces it to 1 (it prints a warning if you set it
higher).
Finally, some processing has to consider every session in the study at once — for instance, assembling a study-level list or a group-level output. For these commands there is nothing to split up across a pool: the work is inherently about the whole study.
Your function is called once, and sinfo is the full SessionList containing every
session in the study, together with options. There is no parallelization across a pool
here, and parsessions is again forced to 1. A plain ReportLog suits this case, as
there is no single session to head the report with, and a log that carries no id of its
own is filed under the command name; a command that does want a header can build a
SessionLog over the list of session ids.