Skip to content

RunPath: ordered step directories (opt-in)

duho.runpath turns a directory of numbered .py files into a single command that runs them in order. It is opt-in — core duho never imports it:

import duho.runpath   # importing it registers the RunPath provider

A RunPath directory is a directory with no __init__.py whose files are NN-name.py steps:

release/
├── 10-build.py
├── 20-test.py
└── 30-publish.py
# 10-build.py — a step's body is its top-level main/run/call, same
# precedence as a module command. It receives the parsed command instance.
def main(cmd):
    cmd._logger_.info("building")

This is the same "bare directory of loose .py files, no __init__.py" shape discovering commands from files uses — what routes a RunPath directory to this runner instead of normal per-file discovery is entirely its NN-name.py filenames.

A shared, per-run context: __main__.py

A RunPath directory may define a __main__.py — the same dunder Python already uses for "this directory's entrypoint" — with up to three optional callables:

# __main__.py — runs once per invocation, before any step
def init(cmd, logger):
    return connect_once()          # ctx handed to every 2-arg step

def success(ctx, cmd, logger):
    logger.info("all steps completed cleanly")

def finally_(ctx, cmd, logger):
    ctx.close()                    # always runs, success or failure
# 20-provision.py — a step opting into ctx just adds a 2nd parameter
def main(cmd, ctx):
    ctx.provision()

A step written (cmd) (no ctx) is unaffected — arity is detected automatically, so old and new steps coexist in the same directory. The ctx argument is only ever supplied when a __main__.py actually exists in the directory; a 2-parameter step in a directory with no __main__.py simply keeps calling with just cmd, so its own second parameter keeps whatever default it declared. init raising is always fatal, regardless of --rcopts strict — every step depends on ctx.

Any module-level name init, success or finally_ in __main__.py is a hook, an imported one included: from myapp.hooks import default_init as init makes that function the init hook. When the file defines __all__, only a listed name is a hook, so from colorama import init next to __all__ = ["success"] does not call colorama's init. Importing under another name, as in from colorama import init as colorama_init, also keeps a name from being a hook.

success runs inside the run, right after the last step and before finally_ — not after it — and only when nothing failed (see "Step outcomes" below); a raising finally_ is logged and swallowed rather than replacing whatever error is already in flight.

Giving steps your app's own signature

Steps are called main(cmd) or main(cmd, ctx). If your app's module commands take a different shape — say run(client, args, logger) — steps and commands disagree, and the same body cannot move between them.

register(step_adapter=...) takes a callable applied to each step's entrypoint just before it runs; it receives the entrypoint and returns the callable to call instead. That makes the step signature an app-wide convention rather than something every step file opts into with a decorator:

import duho.runpath

def adapter(entrypoint):
    def call(cmd, ctx=None):
        return entrypoint(ctx, cmd, cmd._logger_)   # (client, args, logger)
    return call

duho.runpath.register(step_adapter=adapter)

The adapted callable is what arity detection inspects, so a wrapper is free to change the signature. An adapter that returns its argument unchanged leaves duho-native steps alone — which is how an app supports both shapes at once.

Pass None to clear it; omit the argument to leave it unchanged. Unlike base, it applies per step run, so it also affects already-built commands. The default is None: steps are called exactly as written.

register and unregister change process-wide state. A test or a tool that changes it for a while wraps the change in duho.runpath.preserve_registration(), which puts the base, the step adapter and whether the provider is registered back as they were on entry, whatever happened inside. duho.runpath.registration() reads the three as a named tuple, and duho.runpath.step_wants_context(entrypoint) answers, as the runner does, whether a step's entrypoint takes the context.

Ordering and dependencies

  • PRIORITY: int — overrides the NN prefix for ordering.
  • REQUIRED: list[str] — a hard dependency: the named step must run and succeed before this one. A name that is missing or disabled is a warning (or an error under a bare --rcopts strict); a name that IS present, enabled, and actually fails (raises, or a non-zero step return) skips this step too, following THIS step's own strict setting — not just an ordering hint.
  • BEFORE: list[str] / AFTER: list[str] — soft ordering only (no existence/success requirement), styled after systemd's Before=/After=. A missing or disabled name here is silently a no-op — never a warning. Only enabled, selected steps ever enter the ordering graph, so a disabled step's BEFORE/AFTER/REQUIRED can never reorder or gate an enabled one.

Step outcomes

A step returns None (success) or a non-zero int (failure) — handled through the exact same strict-vs-resilient path as a raised exception. The command's own exit code is the MAXIMUM of every step's code (a resiliently swallowed failure contributes 1, since it has no numeric code of its own), so a resilient run that swallowed a failure still reports it via a non-zero exit code instead of always returning 0. __main__.py's success hook only fires when that aggregate stays clean.

A step (or a __main__.py hook) must be synchronous — an async def one has its coroutine closed immediately and raises TypeError, so a mistaken async def is a loud failure instead of a silently-never-awaited coroutine.

Filename-encoded per-step options

A step's filename can carry a leading ! (disabled by default) plus :/;-separated tokens (key, !key, key=value) — the same grammar --rcopts uses per entry:

01-step1.py                       # enabled, strict (defaults)
!02-step2.py                      # disabled by default
02-step2;!enable.py               # same, explicit-token spelling
03-cleanup;!strict.py             # enabled, non-strict for this ONE step

Selecting steps with --rcopts (-O)

--rcopts '!*,test'                 # run only test
--rcopts 'build:!strict'           # build's failure is resilient; every
                                    #   other step is untouched
--rcopts 'strict'                  # run-wide: overrides every step's own
                                    #   setting

See the README for the full precedence rules and strict-vs-resilient semantics.

A complete example

examples/rc/ and examples/runpath_app.py in the repository demonstrate every feature above in one runnable directory: a __main__.py lifecycle, a BEFORE/REQUIRED/AFTER mix, and a filename-encoded !strict step. examples/rc/__main__.py is self-contained (it reads the shared root's label field directly, with a fallback, rather than importing anything from runpath_app.py), so the directory resolves the same way from ANY entry point, including the snippet below saved as its own script at the repo root:

Resolving a RunPath directory into an app uses duho.discovery.CmdBuilder (not discover_commands, which only walks a directory's top-level files — see Discovering commands from files):

import duho
import duho.runpath
from duho.discovery import CmdBuilder
from pathlib import Path

rc_command = CmdBuilder("rc", Path("examples/rc")).command
raise SystemExit(duho.app(commands=[rc_command], name="myapp"))

To see the shared-root-method form (register(base=...), giving steps a real cmd._tag_line_(...) method instead of just data fields), run the example directly instead: python examples/runpath_app.py rc.