Command registry - ULJ-Yale/qunexsdk GitHub Wiki

Command registry and command types

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

How the registry works

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.

Core, extensions, and overriding

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.

Writing a valid command docstring

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.

  1. Call line — the first line consisting of a single ````...```` (double backticks) block. Its contents become the command's example call.
  2. Description paragraph — the text immediately following the call line, up to the first blank line. It becomes the command's short description.
  3. 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 provide type:; it may also provide aliases:, name:, and language:.
  4. Parameters: and Returns: 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.

Python example

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 options only if the function has an options argument; otherwise they are ignored (with a build warning).
  • name: is optional — the command name defaults to the function name.

MATLAB and bash

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 the function line; name, arguments and returns are taken from that function line.
  • bash (.sh): the docstring is the text inside the script's usage() heredoc, and the .. qx_command: block must declare language: bash.

What the declaration is used for

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 batchfile if the command should see the study's parameters. A command that takes --batchfile is 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.

Building the registry

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_registry

This 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=example

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

Command types

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.

Processing commands

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.

processing.session

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.

processing.subject

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

processing.study

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.

⚠️ **GitHub.com Fallback** ⚠️