Whitepaper Extensions - ULJ-Yale/qunexsdk GitHub Wiki

The QuNex extensions whitepaper

QuNex can be extended. You can write your own commands — in Python, in MATLAB, or as bash scripts — put them in a folder of their own, and have them appear as QuNex commands alongside the ones the suite ships with. They are run the same way, they take parameters the same way, they are logged the same way, and they can be sent to a scheduler the same way. A command you wrote this morning is invoked as qunex my_command --parameter=value this afternoon.

This paper explains how that works, end to end: what an extension is made of, how QuNex finds one, how your commands and their parameters are declared, what happens on the way from a command line to your function, and how an extension can replace a command that QuNex itself provides.

It is written for developers who are about to write or maintain an extension, and it assumes no previous acquaintance with the extension code. Two shorter documents sit alongside it. Extensions is the working guide — the same material condensed to what you need while writing one. Command registry and command types describes the docstring format and the command types in detail, and everything it says applies to an extension's commands exactly as it does to the suite's own.

Everything described here is the Python implementation, which is where every QuNex command is dispatched.

1. What an extension is

An extension is a directory whose name begins with qx_, placed somewhere QuNex looks. Inside it are up to three folders of code — python, matlab and bash — plus a couple of optional ones. That is the whole of it. There is no manifest to write, no registration call to make, and no installation step beyond putting the folder in place and running one command.

What makes a function inside that folder a QuNex command is its docstring. A function whose docstring carries a .. qx_command: block is a command; a function whose docstring does not is ordinary code that your commands can call. Nothing else distinguishes the two, which means that turning a working function into a QuNex command is a matter of writing its documentation properly.

Because your commands are described by the same declarations as the suite's own, they inherit everything QuNex does around a command: the parameter tiers, the batch file, the per-run logging, the scheduler, the parallelisation over sessions. You write the part that is yours, and the framework around it is already there.

2. The mental model: a folder QuNex can see, and a registry that describes it

Almost everything that can go wrong with an extension comes from misunderstanding this section, so it is worth reading slowly. There are only two conditions, and both are simple.

QuNex has to be able to see the folder. Every time it runs, QuNex looks in a small, fixed set of places — its search roots — and takes every qx_* folder it finds there to be an extension. Section 3 lists the roots.

The registry has to know your commands. A registry is the list of what a body of code offers, built by scanning that code for qx_command docstrings. The suite has one; your extension gets one of its own, a file called qx_commands.yaml sitting in the extension folder, written by qunex build_qx_extensions. Every time QuNex runs, the suite's registry and every extension's registry are loaded and merged into one list, and that merged list decides whether qunex my_command is a command at all.

Everything else follows from those two. A registry is read from a known place, so QuNex knows which folder each command came from, and it uses that to find the code: your Python modules are put on the Python path, your MATLAB folder is added to MATLABPATH for the duration of a MATLAB call, and your bash scripts are looked up under your own bash folder. Your extension's bin folder goes on PATH, and <EXT>PATH, <EXT>LIB and <EXT>BIN are set, for the QuNex process and everything it starts. None of that needs anything set up in advance — putting the folder somewhere QuNex looks is the whole of the installation.

So there is one habit to keep, and this paper returns to it repeatedly:

Run qunex build_qx_extensions --extensions=<name> after you add, rename or re-document a command. Nothing rebuilds the registry for you, and the registry — not your source — is what QuNex reads. Section 7 covers the command and how to name what it should build.

Sourcing qunex_environment.sh also walks the extensions, and it is worth knowing what that adds. It exports the same per-extension variables into your shell rather than into QuNex's, puts each bin folder on your interactive PATH, and prints a report of what it found. That report is a useful check, and the shell variables are convenient when you are working on an extension by hand — but QuNex does not depend on any of it. This matters most inside a container, where the environment is sourced once at start-up and cannot be sourced again: see Extensions in a container.

3. Where QuNex looks for extensions

QuNex searches three places, in this order:

  1. $QUNEXPATH/qx_extensions — inside the QuNex installation itself;
  2. $TOOLS/qx_extensions — beside it, in the folder that hosts the installation;
  3. every folder listed in the environment variable QUNEXEXTENSIONSFOLDERS, which is a colon-separated list, in the style of PATH.

Each of these is a search root — a folder that contains extensions, not an extension itself. A root holding two extensions looks like this:

/opt/software/qx_extensions/      # a search root
├── qx_example/                   # one extension
└── qx_mylab/                     # another

Any subdirectory of a root whose name starts with qx_ is taken to be an extension. The part after qx_ is the extension's name: qx_example is the extension example.

The first two roots are always searched and need nothing set up. The third is how you keep an extension outside the QuNex installation — under your home directory, on a shared filesystem, wherever the code you maintain actually lives:

# a colon-separated list, like PATH; it just has to be exported in the shell
# you run QuNex from
export QUNEXEXTENSIONSFOLDERS="$HOME/qunex_extensions:/data/shared/qx_extensions"

# QuNex reads it on every call -- nothing has to be re-sourced
qunex example_hello --example_name=QuNex

If you name a folder that does not exist, QuNex says so and carries on with the rest:

WARNING: extensions folder '/data/shared/qx_extensions' does not exist or is not a folder, skipping it.

The order of the roots matters when the same extension name appears in more than one of them: the later root wins. That gives you a way to try a modified copy of a shared extension without touching the shared one.

An older spelling of this variable, QUNEXEXTENSIONFOLDERS — without the second S — is still read, so an installation that sets it keeps working. It prints a notice naming QUNEXEXTENSIONSFOLDERS as the spelling to move to, and a folder named under both is registered once.

What sourcing the environment adds

Sourcing qunex_environment.sh walks the same roots and reports what it found:

QuNex extensions identified
---> Registering extension qx_example
    ... setting QXEXAMPLEPATH to '/opt/software/qx_extensions/qx_example'
    ... setting QXEXAMPLEBIN to '/opt/software/qx_extensions/qx_example/bin'
    ... added /opt/software/qx_extensions/qx_example/bin to PATH
    ... added /opt/software/qx_extensions/qx_example/python to QXEXTENSIONSPY
    ... added /opt/software/qx_extensions/qx_example/matlab to MATLABPATH

This listing is the quickest confirmation that a search root is set the way you think it is, and the per-folder lines tell you which parts of your extension were recognised. Read it rather than scrolling past it — but do not treat its absence as the problem. QuNex finds an extension by itself, so a command from an extension the environment never saw still runs.

The concrete difference is whose environment gets the variables. When you source the script, they are set in your shell, which is what makes an extension's bin scripts callable by name at your own prompt. When QuNex runs, it sets the same variables inside its own process, which is what makes them available to your command's code and to anything that code starts. Neither can set the other's.

Three variables are set per extension for your own use, named after the extension folder with the underscores removed and the rest upper-cased — qx_example gives QXEXAMPLE:

Variable Set to
<EXT>PATH the extension folder itself
<EXT>BIN its bin folder, when it has one
<EXT>LIB its lib folder, when it has one

So qx_example's code can refer to $QXEXAMPLEPATH/templates/mask.nii.gz and find its own data wherever the extension has been installed.

Extensions in a container

QuNex is often run from a container, and containers have one property that shapes how an extension is deployed: the QuNex environment is sourced once, when the container starts, and sourcing it again does nothing. An extension installed inside a running container therefore cannot be picked up by the environment script, no matter how many times you source it.

QuNex works anyway, for exactly the reason section 2 gives — it finds the extension itself, and resolves your Python, MATLAB and bash code from the registry rather than from the environment. Everything QuNex runs works. The one thing that does not is calling an extension's bin script by name at the container's interactive prompt, since that shell's PATH was fixed before the extension existed. Add the folder yourself if you want it there:

export PATH=/path/to/qx_example/bin:$PATH

The tidier arrangement, and the one to prefer, is to prepare the extension outside the container and bind it in when the container starts. Build its registry on the host:

# the variable names the folder that holds qx_example; build_qx_extensions
# leaves the installation's own registry alone
QUNEXEXTENSIONSFOLDERS=/path/to/extensions \
    qunex build_qx_extensions --extensions=example

The registry that writes holds only paths relative to the extension, so the same file serves at whatever mount point the container gives it. Then bind the folder that holds the qx_* folders onto $TOOLS/qx_extensions, which inside a container is /opt/qx_extensions — one of the two roots QuNex always searches, so no environment variable is involved at all:

qunex_container --interactive \
    --container=/path/to/qunex_suite-latest.sif \
    --bind=/path/to/extensions:/opt/qx_extensions

Because the extension is in place before the container starts, the container's own sourcing of the environment registers it, and everything works — including the bin scripts at the interactive prompt.

Developing an extension from inside the container

Preparing the extension on the host suits a finished extension being deployed. While you are still writing one, it is easier to bind the folder in and work inside the container, rebuilding as you change things:

# the extension lives on the host and is bound in, so edits and rebuilds
# both land on the host and survive the container
qunex build_qx_extensions --extensions=example
qunex example_hello --example_name=Container

This works because an extension's registry is written beside its own code. The code is on a bound folder, so the registry lands there too, and the next container you start already has it. Nothing has to be copied back out.

The one thing you cannot rebuild from inside is the suite's registry. It lives on the container image, which is mounted read-only, so qunex build_qx_registry cannot write it. Rather than failing on whatever the filesystem reports, it says what has happened and what to do instead:

---> ERROR in completing build_qx_registry:
     Registry location is not writable
     Could not write the registry to: /opt/qunex/qx_commands.yaml
     The installation's own registry cannot be written, which is normal inside a
     container: it lives on the container image, and that is read only.
     Registering an extension does not need it. Build the extension instead:
         qunex build_qx_extensions --extensions=<name>

Nothing about registering an extension needs that registry rebuilt, which is the reason build_qx_extensions exists as a command of its own: the distinction between the suite's registry and an extension's is QuNex's business, not something an extension author should have to know about in order to say they are not touching it.

The same message appears if the extension folder itself is read-only — an extension baked into an image rather than bound in — and the answer there is to bind it in from the host, which is what the arrangement above does.

4. The anatomy of an extension

Here is a complete extension. Every entry is explained below, and none of them is compulsory — an extension with nothing but a python folder is perfectly valid.

qx_example/
├── qx_commands.yaml          # the registry, written for you by build_qx_extensions
├── python/
│   ├── qx_modules            # optional: modules QuNex imports on every call
│   ├── qx_example_options.py # the parameters the commands take
│   └── qx_example_commands.py# the commands themselves
├── matlab/
│   ├── matlabpaths           # optional: further folders to put on MATLABPATH
│   └── example_matlab_greet.m
├── bash/
│   └── example_bash_greet.sh # bash scripts that are QuNex commands
├── bin/
│   └── example_helper.sh     # plain scripts, on PATH, not QuNex commands
└── lib/                      # anything else your code needs; exported as <EXT>LIB

qx_commands.yaml is generated. You never write or edit it, and you do not need to read it, although it is plain YAML and reading it is a good way to check that a command was registered the way you meant. It is described in section 7.

python/ holds Python commands and Python library code. The folder is put on the Python path for you, and qx_modules — an optional file naming the modules QuNex should import on every call — is covered in section 6.

matlab/ holds MATLAB functions. The folder is added to MATLABPATH as a whole, so every function in it is callable from MATLAB whether or not it is a QuNex command. If your MATLAB code is organised into subfolders, list them — one per line, relative to the matlab folder — in a file called matlabpaths, and they are added too.

bash/ holds bash scripts that are QuNex commands, and bin/ holds bash scripts that are not. The distinction is genuine and is explained in section 8.

lib/ has no special meaning beyond being exported as <EXT>LIB. Templates, reference data, compiled helpers — anything your commands need at run time can live there.

5. A command is a docstring

A function becomes a QuNex command by carrying a .. qx_command: block in its docstring. The format is the same one the suite's own commands use, and Command registry and command types is where it is specified in full. This section covers what you need to write one; read that page as well before writing many.

A registrable docstring has four parts, and this Python command shows all of them:

def example_hello(example_name="world", example_times=1):
    """
    ``example_hello [--example_name=<name>] [--example_times=<n>]``

    Greets whoever is named -- the smallest useful command an extension can
    ship.

    ..  qx_command:
        type: utility

    Parameters:
        --example_name (str, default 'world'):
            Who to greet.

        --example_times (int, default 1):
            How many times to greet them.

    Returns:
        --greeting (str):
            The greeting, which is also printed.
    """

    greeting = "\n".join(["Hello, %s!" % example_name] * int(example_times))
    print(greeting)
    return greeting

Reading it from the top:

  1. The call line. The first line of the docstring, wrapped in double backticks. It is the example invocation shown in the command's help, and it is required — a docstring without one is not registered.
  2. The description. The paragraph immediately after the call line, up to the first blank line. It becomes the command's one-line description in listings.
  3. The qx_command block. A reStructuredText comment, so it does not appear in rendered documentation. It must give a type:; it may also give name:, aliases: — each of which is a second name the command answers to, qunex <alias> running it exactly as qunex <name> does — and, for MATLAB and bash, language:. The type is what tells QuNex how to run the command, and the choices — utility, qa, matlab, and the three processing.* types — are described on Command registry and command types.
  4. Parameters: and Returns:. One entry per parameter, written as --name (type[, default <value>]): with the description indented beneath it. The trailing colon on the entry line is required. These entries are not decoration: section 9 explains what QuNex does with them.

The command above is a utility command — it takes its parameters as ordinary function arguments, does its work, and returns. A processing command is the other common shape: QuNex hands it one session at a time, along with the merged options, and expects a log object back.

import qx_utilities.general.log as gl


def example_greet_sessions(sinfo, options, overwrite=False, thread=0):
    """
    ``example_greet_sessions [... processing options]``

    Greets every session in the batch file, once per session -- the shape of a
    processing command, with none of the processing.

    ..  qx_command:
        type: processing.session

    Parameters:
        --batchfile (str, default ''):
            The batch file listing the sessions to greet.

        --sessionsfolder (str, default '.'):
            The path to the study sessions folder.

        --example_greeting (str, default 'Hello'):
            The greeting to use.

        --example_shout (flag, default False):
            Greet in capitals.

    Returns:
        --log (SessionLog):
            The command's log, carrying its report and its status.
    """

    # the log this command reports into; it writes its own header as it is built
    log = gl.SessionLog(sinfo, options, "Example greeting")

    # sinfo is this one session; options are the merged parameters for it
    greeting = "%s, %s!" % (options["example_greeting"], sinfo["id"])
    if options["example_shout"]:
        greeting = greeting.upper()

    log.step(greeting)

    # the summary, and the log itself, which is what a processing command returns
    return log.finish("session greeted")

The signature — (sinfo, options, overwrite=False, thread=0) — is fixed, and QuNex calls your function once per session, running several sessions concurrently when the run asks for it. What arrives as sinfo depends on the processing.* type you chose, and Command registry and command types sets out all three. The log object and what to do with it are described on Logging.

Notice that example_greet_sessions declares batchfile and sessionsfolder among its parameters even though it never reads them directly. A processing command should: it is how the run's session list and study folder reach it, and a command that does not declare them cannot be given them.

6. Python commands

Python is the language QuNex itself is written in and the one an extension will most often use. Very little is asked of you: put your modules in the extension's python folder, and QuNex adds that folder to the Python path on every call. A command's module is imported the first time the command is actually run, so nothing you write costs anything until it is used.

qx_modules

Some of what an extension declares has to be in memory before any command runs — notably the parameter defaults described in section 9, which QuNex needs while it is merging the parameters for the run. qx_modules is how you say which modules those are. It is an optional file in the extension's python folder, listing one module name per line:

# The python modules QuNex imports when it starts. One name per line; a line
# starting with '#' is a comment, an empty line is skipped.

qx_example_options

A name may be a module (qx_example_options for qx_example_options.py) or a package (a folder with an __init__.py). Each one is imported on every qunex call, whether or not the command being run has anything to do with your extension.

That is the whole of its job, and it is worth being clear about what does not belong in it. The modules holding your commands should not be listed: QuNex imports a command's module when the command is run, so naming it here only makes every unrelated call a little slower. And an extension that declares no parameters of its own needs no qx_modules file at all — its commands are found, imported and run without one.

Using QuNex's own library

Your extension's code can import from the QuNex Python library directly:

import qx_utilities.general.log as gl              # the log classes
import qx_utilities.general.exceptions as ge       # CommandError, CommandFailed

The suite's python folder is already on the path by the time your module is imported, so no path manipulation is needed. Developer utilities and classes describes what is available.

7. Building the registry

Once the docstrings are written, build the registry. As an extension author the command you want is build_qx_extensions, which builds the registries of the extensions you name and leaves the QuNex installation's own registry untouched — it is faster, it works on an installation you have no write access to, and it keeps your work out of a file that is not yours:

# rebuild the registry of the extension called qx_example
qunex build_qx_extensions --extensions=example

You have to say what to build. The name is taken with or without the qx_ prefix, several may be given separated by commas, and all means every extension QuNex can find:

qunex build_qx_extensions --extensions=qx_example,mytools
qunex build_qx_extensions --extensions=all

A name that matches no extension stops the build and lists the ones that were found, rather than quietly building nothing — a misspelt name would otherwise look exactly like a build that worked, and only show up much later as Requested command is not supported.

The command reports each command as it registers it, and closes with a summary of the extensions it built:

--> Leaving the core command registry as it is: /opt/qunex/qx_commands.yaml
    -> registering qx_example_commands.example_hello
       adding options: ['batchfile', 'sessionsfolder', 'example_greeting', 'example_shout']
    -> registering qx_example_commands.example_greet_sessions
    -> registering example_matlab_greet.m
    -> registering example_bash_greet.sh

----------------------------------------------------------------
Registry built!

--> Built 1 extension registry:
    - extension:example: /opt/software/qx_extensions/qx_example/qx_commands.yaml

Asking what is there

--extensions=check builds nothing. It lists the extensions QuNex can see instead, each with the folder it was found in and whether it has a registry yet, and then says how to name them:

--> Extensions QuNex can see (2):
    qx_example  /opt/qx_extensions/qx_example  [registry built]
    qx_mytools  /opt/qx_extensions/qx_mytools  [no registry yet]

--> Name the ones to build:
        qunex build_qx_extensions --extensions=<name>[,<name>...]
    or build every one of them:
        qunex build_qx_extensions --extensions=all

This is the quickest way to confirm that QuNex is looking where you think it is. An empty listing almost always means QUNEXEXTENSIONSFOLDERS names the extension folder itself rather than the folder containing it, and the command says as much when it finds nothing.

Rebuilding the suite's own registry

qunex build_qx_registry is the command underneath, and it rebuilds the suite's registry as well as the extensions', which is what an installation needs after QuNex itself has been changed. It takes the same --extensions to limit which extensions it covers, --build_extensions=no to leave them out entirely, and --build_core=no to skip the suite's registry — which is what build_qx_extensions does for you. qunex build_qx_registry --help lists the rest.

When the registry cannot be written there

Both commands check where the registry is going before writing it, because the two ways it can go wrong have very different answers.

If the location cannot be written, that is an error. For the suite's own registry it is also the normal state of affairs inside a container, where the registry sits on a read-only image, so the message says so and names what to run instead:

---> ERROR in completing build_qx_registry:
     Registry location is not writable
     Could not write the registry to: /opt/qunex/qx_commands.yaml
     The installation's own registry cannot be written, which is normal inside a
     container: it lives on the container image, and that is read only.
     Registering an extension does not need it. Build the extension instead:
         qunex build_qx_extensions --extensions=<name>

The same message appears outside a container on a shared installation you have no write access to, and for an extension whose own folder is read-only.

A registry that is already current is never rewritten, so rebuilding one that happens to live somewhere read-only is not an error — there is simply nothing to do. An extension shipped inside a container image with its registry already built rebuilds cleanly for exactly that reason.

The second case is a location that can be written but does not outlive the container — a writable scratch layer rather than a folder bound in from the host. That is a warning rather than an error: the registry is written and works for as long as the container runs, and is gone afterwards.

An extension's registry is written beside the extension, as qx_commands.yaml in the extension folder, and it records paths relative to that folder rather than absolute ones. It therefore travels with the extension: copy the folder somewhere else, or mount it at a different path inside a container, and the same file still resolves. This is why an extension can be prepared on one machine and used on another with no build step in between.

If the build finds no commands anywhere, it says so plainly:

--> No extension registries were built: no extension was found with commands in it.

which almost always means the search root names the extension folder itself rather than the folder containing it.

Read this output. It is where a malformed docstring shows up, and the failure is quiet by design — a function that is not a command is not an error, so a command that fails to register is simply absent from the listing. A command is excluded when its docstring has no call line, or no qx_command block, or no type: in that block. Warnings are printed for each and collected at the end of the run.

Two clashes are treated more firmly. Within a single registry — yours, or the suite's — every command name and every alias must be unique, and a duplicate stops the build with an error naming both claimants. Across registries a shared name is not a clash at all; it is an override, which section 10 covers.

Rebuild after every change to a command's declaration: a new command, a removed one, a renamed one, an edit to the qx_command block, the call line, the description, or the Parameters: entries. Nothing rebuilds automatically, and the registry, not the source, is what QuNex reads.

The generated file is worth one look, because it shows you exactly what QuNex now believes about your command:

-   name: example_hello
    aliases: []
    path: qx_example_commands.example_hello    # how the code is found
    language: python
    call: example_hello [--example_name=<name>] [--example_times=<n>]
    description: Greets whoever is named -- the smallest useful command an extension can ship.
    type: utility
    args:                                      # from the function signature
    -   name: example_name
        type: str
        default: world
        description: Who to greet.
    -   name: example_times
        type: int
        default: '1'
        description: How many times to greet them.
    options: []
    returns:
    -   name: greeting
        type: str
        default: null
        description: The greeting, which is also printed.
    origin: extension:example                  # which registry it came from

8. MATLAB and bash commands

A command does not have to be written in Python. The docstring format is the same; only where the docstring lives and how QuNex calls the code differ.

MATLAB

A MATLAB command is a .m file in your extension's matlab folder, with the qx_command block in the help comment block that follows the function line:

function [] = example_matlab_greet(name, times)

%``example_matlab_greet(name, times)``
%
%   Greets whoever is named, from MATLAB.
%
%   ..  qx_command:
%       type: matlab
%
%   Parameters:
%       --name (str, default 'world'):
%           Who to greet.
%
%       --times (int, default 1):
%           How many times to greet them.
%

if nargin < 2 || isempty(times), times = 1; end
if nargin < 1 || isempty(name),  name  = 'world'; end

for n = 1:times
    fprintf('Hello, %s! (from MATLAB)\n', name);
end

The command's name and its arguments are read from the function line, and QuNex calls the function by name. It adds the extension's matlab folder — and whatever matlab/matlabpaths lists beside it — to MATLABPATH for the duration of the call, working from the registry record, so the call resolves wherever the extension is installed. Arguments are passed positionally, in the order of the function line, and each one is quoted according to the type you documented for it — a str is quoted, an int is not, an empty value becomes []. Documenting the type of every argument therefore matters more here than anywhere else: an argument with no documented type is passed through as written, with a warning, and one badly quoted argument shifts every argument after it.

Because the arguments are positional, give every one of them a sensible default inside the function, as the two nargin lines above do. QuNex passes a value for every declared argument, using an empty value for the ones the user did not name.

bash

A bash command is a .sh script in your extension's bash folder. Its docstring is the text inside the script's usage() heredoc, and its qx_command block must declare language: bash:

#!/bin/bash

usage() {
    cat << EOF
``example_bash_greet``

Greets whoever is named, from a bash script.

..  qx_command:
    type: utility
    language: bash

Parameters:
    --example_name (str, default 'world'):
        Who to greet.

EOF
    exit 0
}

if [[ "$1" == "--help" ]] || [[ "$1" == "-h" ]]; then usage; fi

example_name="world"

# QuNex passes every declared parameter as --name='value'
for arg in "$@"; do
    case ${arg} in
        --example_name=*) example_name="${arg#*=}" ;;
    esac
done

echo "Hello, ${example_name}! (from bash)"

QuNex calls the script with each declared parameter as --name='value'. A bash script has no signature for QuNex to read, so the Parameters: block is the script's interface: a parameter the docstring does not declare is never passed, and one it declares must be spelled exactly as the script reads it.

bin is not bash

The two folders are for different things, and the difference is easy to trip over.

  • bash/ holds QuNex commands. A script here is registered from its docstring and invoked as qunex example_bash_greet --example_name=QuNex.
  • bin/ holds plain scripts. The folder is put on PATH, so a script here is callable by its own name from your extension's Python, MATLAB or bash code, and from anything those start. It is not scanned for docstrings and is not a QuNex command.

Use bin for the helper that your own command shells out to, and bash for the script that is the command.

QuNex puts bin on the PATH of its own process, which covers every command it runs. Getting it onto the PATH of the terminal you are typing in is a separate thing, and it happens when qunex_environment.sh is sourced with the extension already in place — see section 3. Remember also that a script has to be executable: a copy made through an editor or an archive that drops file modes fails with Permission denied rather than command not found.

9. Declaring parameters

Every command declares what it accepts, and this is where an extension most often goes subtly wrong, so it is worth being precise about the two declarations involved and what each one does.

The docstring says what the command accepts

The Parameters: block of a command's docstring, together with its function signature, is the definitive list of what that command may be given. QuNex uses it on every run:

  • For a utility command it is a gate. A parameter that is neither in the signature nor among the run-wide parameters QuNex itself understands is rejected before anything is logged:

    ERROR: Extra argument bogus is not valid! Please check your command!
    
  • For every command it is what narrows the parameter tiers. QuNex merges defaults, the batch file header and the command line into one set of parameters for the run, and what your command is actually called with is the part of that set your command declares.

  • For MATLAB and bash commands it is the whole interface, since there is no Python signature to read.

  • It is also what the parameter report prints before every run, so a declared parameter is a parameter the user can see the value and the origin of.

For a Python command, parameters that appear in the docstring but not in the signature are attached to the command as options — but only if the signature has an options argument, which processing commands do and utility commands do not. A utility command that documents a parameter it does not take gets a build warning and the parameter is dropped.

arglist says what a parameter defaults to and how it is read

The docstring declares the parameter; it does not give it a value. That is arglist, a list in a module named in your qx_modules:

# qx_example_options.py -- listed in qx_modules, so QuNex imports it at start-up


def is_set(value):
    """Reads a flag's value: the flag itself, or the usual spellings of yes."""
    return value in (True, "yes", "true", "TRUE", "True", "1")


# each entry is [name, default, converter], and may carry a description as a fourth
arglist = [
    ["# ---- qx_example settings"],           # a one-element entry is a heading
    ["example_name", "world", str],           # default "world", read as a string
    ["example_greeting", "Hello", str, "The greeting to use."],
    ["example_times", 1, int],                # "3" on the command line becomes 3
    ["example_shout", False, is_set],         # read by the function above
]

Each entry gives three things: the parameter's name, its default, and a callable that converts what was written into what the command should see. A fourth element, a description, is accepted and may be written for the reader's benefit; QuNex ignores it. A one-element entry is a heading, used only to group the listing.

The converter is usually str, int or float, and it can be any function of one argument, as is_set shows. It has to be something QuNex can call: a type annotation such as Optional[str] looks like a type but cannot be applied to a value, and QuNex reports it and leaves the parameter as it was written rather than failing the run:

WARNING: parameter `example_times` declares `Optional[int]` as its convert function,
which can not be called. The parameter keeps the value it was given, unconverted.

What arglist buys you is the default and the conversion. A parameter with an arglist entry has a value on every run, whether or not anybody named it, and that value has been through the converter. A parameter without one has a value only when somebody names it, and that value is the string they typed.

For a utility command this rarely matters: the parameter is a function argument with a default in the signature, and that default applies. For a processing command it matters a great deal, because a processing command reads its parameters out of the options dictionary:

greeting = options["example_greeting"]      # KeyError, if nothing declared a default

So the rule is: a parameter that a processing command reads from options needs an arglist entry. Declare it in the docstring so the command accepts it and the user can see it; declare it in arglist so it is always there.

Flags

A flag is a parameter given without a value. Declare it in flaglist, alongside arglist, as the name and the value the flag sets:

# `--example_shout` means `--example_shout=True`
flaglist = [
    ["example_shout", True],
]

Give a flag an arglist entry as well, so that it has a value — normally False — on the runs where it is not given.

The other declarations

The same module can carry a few more lists and dictionaries, all optional, all collected across every extension QuNex loads. They are how an extension takes part in the bookkeeping the suite does for its own parameters:

Name Kind What it does
arglist list parameter defaults and converters
flaglist list parameters that may be given without a value
extra_parameters list parameters accepted by every command, not just yours
deprecated_commands dict old command name → the name that replaces it
deprecated_parameters dict old parameter name → its replacement
deprecated_values dict old parameter value → its replacement
to_impute list parameters whose value is filled in from another when unset
towarn_parameters dict parameters that produce a warning when used
logskip_commands list commands that write no log

Most extensions need only the first two.

10. Overriding a command QuNex provides

When the registries are merged, commands are keyed by name, and the extension's entry wins. An extension that ships a command called check_study replaces the QuNex command of that name for every run in that environment:

$ qunex check_study
...
THIS IS THE EXTENSION'S check_study, not the core one.

This is deliberate, and it is the intended way to change a command's behaviour for a study, a site or an experiment without modifying the QuNex installation. Nothing is altered on disk: the suite's command is untouched, and removing the extension — or removing its root from QUNEXEXTENSIONSFOLDERS — restores it.

Three things follow, and the third is the one to remember.

Your replacement is a whole command. It has its own docstring, its own parameters and its own type, and it need have nothing in common with the command it replaces beyond the name. QuNex will run it exactly as the declaration describes.

Overriding applies between extensions too. Two extensions offering the same command name are resolved by search-root order, later winning — which is what makes a local copy of a shared extension a usable way to test a change.

An override announces itself in the run's own record. Above the parameter table — once per run, not once per session — QuNex says which extension the command came from, and, when it stands in for a command of the same name, what it replaced:

---> Command check_study is provided by extension example, replacing the core command of the same name

---> Parameters for check_study
...

A command that comes from an extension without replacing anything gets the first half of that line on its own:

---> Command example_hello is provided by extension example

and a command from the suite itself prints no such line at all. Because the line goes into the run's log as well as onto the screen, a study whose results differ from another's leaves an answer behind rather than a mystery.

Even so, override sparingly and deliberately. To see what an override actually does before running it, ask for its help — that renders the docstring of whichever implementation is in effect:

qunex check_study --help

11. A worked example, end to end

The extension used throughout this paper is real and is shipped with this wiki, in Examples/qx_example. This section installs it and runs everything in it.

Install it

# clone this wiki, if you have not already
git clone [email protected]:ULJ-Yale/qunexsdk.wiki.git

# put the extension in a search root of your own -- a folder that holds
# qx_<name> folders, not the extension folder itself
mkdir -p $HOME/qunex_extensions
cp -r qunexsdk.wiki/Examples/qx_example $HOME/qunex_extensions/

# tell QuNex where to look
export QUNEXEXTENSIONSFOLDERS="$HOME/qunex_extensions"

That is the installation. The example ships with its registry already built, so its commands are available immediately:

qunex --a | grep example
- example_bash_greet: Greets whoever is named, from a bash script.
- example_greet_sessions: Greets every session in the batch file, once per session.
- example_hello: Greets whoever is named -- the smallest useful command an extension can ship.
- example_matlab_greet: Greets whoever is named, from MATLAB.

If nothing comes back, the search root is wrong — QUNEXEXTENSIONSFOLDERS has to name $HOME/qunex_extensions and not $HOME/qunex_extensions/qx_example.

Rebuild the registry

The shipped registry describes the example as it stands. The moment you change a docstring — which you will, as soon as you use the example as a starting point — rebuild it:

qunex build_qx_extensions --extensions=example

Its output is the listing shown in section 7: four commands registered, one extension registry written, and the QuNex installation's own registry left alone.

Run a utility command

qunex example_hello --example_name=QuNex --example_times=2
--- Full QuNex call for command: example_hello

qunex example_hello --example_name="QuNex" --example_times="2"

---------------------------------------------------------

---> Command example_hello is provided by extension example

---> Parameters for example_hello

     parameter       source         value
     ------------------------------------
     example_name    command line   QuNex
     example_times   command line   2
     ------------------------------------

started running example_hello at 2026-08-21 16:25:07, track progress in
/home/user/qunex_logs/2026-08-21_16.25.07_example_hello/comlogs/tmp_example_hello_....log
call: gmri example_hello example_name="QuNex" example_times="2"
-----------------------------------------
Hello, QuNex!
Hello, QuNex!

-----------------------------------------
Finished at 2026-08-21 16:25:07

---> Successful completion of task at 2026-08-21 16:25:07

Everything around your two lines of output — the line naming the extension the command came from, the call echo, the parameter table with the origin of each value, the comlog, the closing status — is what the command inherited by being a QuNex command. None of it is in the extension's code.

Run a processing command

qunex example_greet_sessions \
    --sessions="s01,s02" \
    --sessionsfolder=/data/study/sessions \
    --example_greeting=Hi \
    --example_shout
---> Command example_greet_sessions is provided by extension example

---> Parameters for example_greet_sessions

     parameter          source         value
     ---------------------------------------
     batchfile          default
     example_greeting   command line   Hi
     example_shout      command line   True
     overwrite          default        False
     sessionsfolder     command line   /data/study/sessions
     ---------------------------------------

Starting multiprocessing sessions in s01,s02 with a pool of 1 concurrent processes

Starting processing of session s01 at Friday, 21. August 2026 16:25:51

------------------------------------------------------------
Session id: s01
[started on Friday, 21. August 2026 16:25:51]
Running Example greeting [HCPStyleData] ...
---> HI, S01!

Example greeting completed on Friday, 21. August 2026 16:25:51
------------------------------------------------------------

...

---> Final report for command example_greet_sessions
     2 run, 2 successful, 0 failed, 0 did not complete
     Successful:
     ... s01 ---> session greeted
     ... s02 ---> session greeted
---> Successful completion of all tasks

The session loop, the concurrency, the per-session log with its header and footer, and the final report across sessions are all provided. The extension supplied one line of greeting per session, and --example_shout — the flag declared in flaglist and given a default by arglist — arrived as True and was read by the extension's own converter.

Run the MATLAB and bash commands

qunex example_matlab_greet --name=QuNex --times=2
Running:
>>> example_matlab_greet('QuNex', 2)

Hello, QuNex! (from MATLAB)
Hello, QuNex! (from MATLAB)

---> Successful completion of task

The line beginning >>> is the MATLAB call QuNex composed from your declaration: the arguments in the order of the function line, the string quoted and the integer not, exactly as their documented types said.

qunex example_bash_greet --example_name=QuNex
Running:
>>> bash /home/user/qunex_extensions/qx_example/bash/example_bash_greet.sh --example_name='QuNex'

Hello, QuNex! (from bash)

---> Successful completion of task

Here too the >>> line shows what QuNex composed: the script found under the extension's own bash folder, and each declared parameter passed as --name='value'.

And the script in bin, which is not a QuNex command, is on the path of anything QuNex runs — so a command of yours can simply call it by name:

example_helper.sh
example_helper.sh: Fri Aug 21 16:27:59 UTC 2026

At your own shell prompt it is on the path only if qunex_environment.sh was sourced with the extension already in place; otherwise add $HOME/qunex_extensions/qx_example/bin to PATH yourself.

12. When something does not work

Four failures account for most of the trouble, and each has a distinctive symptom.

"Requested command is not supported."

ERROR: example_hello ---> Requested command is not supported. Refer to general QuNex usage.

The registry does not have your command. Either build_qx_registry has not been run since you wrote it, or it was run and your command was not registered — check the build output for your command's name, and check the docstring for the call line and the qx_command block with its type:. If no extension at all was found, the search root is the thing to check: QUNEXEXTENSIONSFOLDERS must name the folder containing qx_example, not qx_example itself.

ModuleNotFoundError

ModuleNotFoundError: No module named 'nibabel'

The command was found and dispatched, and something it imports was not there. Read the module name in the message: if it is one of your own, the registry is describing source that has since been renamed or moved, and a rebuild fixes it. If it is a third-party package, your extension depends on something the QuNex installation does not have, and it has to be installed into the Python environment QuNex runs under.

KeyError on one of your own parameters

  File ".../qx_example_commands.py", line 76, in example_greet_sessions
    greeting = "%s, %s!" % (options["example_greeting"], sinfo["id"])
KeyError: 'example_greeting'

A processing command read a parameter out of options that has no arglist entry, on a run where nobody named it. Add the entry, and check that the module holding it is named in qx_modules — a declaration in a module QuNex never imports is a declaration that never happens.

A parameter is rejected

ERROR: Extra argument bogus is not valid! Please check your command!

A utility command was given a parameter it does not declare. Add it to the signature and to the Parameters: block, and rebuild the registry — the registry, not the source, is what the check reads, so a parameter you added this minute is not accepted until the build has run.

A checklist

Before asking why an extension does not work, walk this list:

  1. Is the extension folder named qx_something, and is it inside a search root rather than being one?
  2. Is QUNEXEXTENSIONSFOLDERS exported in the shell you are running qunex from, and does QuNex report no warning about the folder it names?
  3. Has qunex build_qx_extensions --extensions=<name> been run since the last docstring change, and did it list your command? If you are unsure QuNex can see the extension at all, qunex build_qx_extensions --extensions=check lists what it finds.
  4. Does every command's docstring have a call line, a qx_command block and a type:?
  5. Does every parameter a processing command reads from options have an arglist entry, in a module named in python/qx_modules?
  6. For MATLAB commands, does every argument have a documented type?
  7. For bash commands and bin scripts, is the file executable?
⚠️ **GitHub.com Fallback** ⚠️