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. init
raising is always fatal, regardless of --rcopts strict — every step
depends on ctx.
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.
Ordering and dependencies
PRIORITY: int— overrides theNNprefix for ordering.REQUIRED: list[str]— a hard dependency: the named step must run and succeed before this one. Missing/disabled is a warning, or an error under strict.BEFORE: list[str]/AFTER: list[str]— soft ordering only (no existence/success requirement), styled after systemd'sBefore=/After=. A missing or disabled name here is silently a no-op — never a warning.
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;!enabled.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.
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"))