cw — your functions, as a command-line tool
import cw
def greet(name, *, loudly=False):
"""Say hello to someone."""
return f"HELLO {name}" if loudly else f"hello {name}"
raise SystemExit(cw.dispatch(greet)) # that is the whole CLI
$ python greet.py world --loudly
HELLO world
pip install cw — no dependencies, MIT, and import cw costs stdlib only.
What it does
You hand cw.dispatch a function, a list of functions, a dict, or a module. It reads the
signatures, builds an argparse parser, calls the function you asked for, prints what it
returned, and returns an exit code. Nothing to decorate, nothing to declare.
$ python greet.py --help
usage: greet.py [-h] [-l] name
Say hello to someone.
positional arguments:
name -
options:
-h, --help show this help message and exit
-l, --loudly False
$ python greet.py
usage: greet.py [-h] [-l] name
greet.py: error: the following arguments are required: name
$ echo $?
2
The parser cw builds is a plain argparse.ArgumentParser, never a subclass. That is
deliberate and load-bearing: argcomplete.autocomplete() is argparse-typed at its
signature, so shell completion works on a cw CLI with the same
# PYTHON_ARGCOMPLETE_OK marker and no adapter.
More than one command
Pass anything with several functions in it. A callable value is a command; a mapping or iterable value is a group:
import cw
def add(a: int, b: int):
"""Add two numbers."""
return a + b
def ls(path=".", *, long=False):
"""List a directory."""
return [f"{path}/one", f"{path}/two"]
COMMANDS = {"add": add, "list": ls, "git-ops": {"add": add}}
raise SystemExit(cw.dispatch(COMMANDS, prog="tool"))
$ tool --help
usage: tool [-h] {add,list,git-ops} ...
positional arguments:
{add,list,git-ops}
add Add two numbers.
list List a directory.
git-ops
options:
-h, --help show this help message and exit
$ tool add 2 3
5
$ tool list -p /tmp
/tmp/one
/tmp/two
Six forms of obj, each decided by the value, never by a string DSL:
obj |
becomes |
|---|---|
| any callable | one command, with no command word at all |
[f, g] — any iterable |
commands named from each __name__ |
{'name': f} — a mapping |
commands named by the key |
{'grp': <any of the above>} |
a group (one level deep, argh's limit) |
| a module, or any object | its public callable attributes; __all__, if present, is the name list |
'pkg.mod:name' — a string |
always exactly one command, imported lazily |
A name from a key or __all__ beats __name__, and is then hyphenated:
{'parse_pth_paths': f} gives you parse-pth-paths.
What a command's return value does
None prints nothing. A list, a tuple or a generator prints one line per item — lazily,
flushing as it goes, so a long-running command streams. Anything else prints as one line,
dicts included. 0, False and '' do print.
>>> import cw
>>> def lines():
... return ['a', 'b']
>>> cw.dispatch(lines, [])
a
b
0
Raise cw.CommandError for an expected failure: one line to stderr, no traceback, and an
exit code you choose. Any other exception keeps its traceback, because a bug deserves one.
config — this call's particulars
config is a plain dict, shaped exactly like obj and keyed the way you type it on the
command line. Its leaves are add_argument keyword arguments:
CONFIG = {
"path": {"help": "the directory to list", "metavar": "DIR"},
"long": {"flags": ["-l", "--long"], "help": "one line per entry"},
}
cw.dispatch(ls, config=CONFIG, prog="ls")
$ ls --help
usage: ls [-h] [-p DIR] [-l]
List a directory.
options:
-h, --help show this help message and exit
-p DIR, --path DIR the directory to list (default: '.')
-l, --long one line per entry (default: False)
For several commands it nests the same way obj does —
{'git-ops': {'add': {'a': {'help': '...'}}}} — and a key that names no command, group or
parameter is a startup error, listing the names that do exist. That is on purpose: a
mis-keyed config entry that silently does nothing is the failure mode this rule exists to
kill.
Two leaf values are not add_argument kwargs:
cw.HIDEremoves a parameter from the command line entirely, leaving it to its own default. This is how you keep a dependency-injection parameter out of the CLI.{'codec': callable}decodes a parsed token after parsing — the place for astr -> objectconversion thatargparse'stype=would get wrong, because argparse appliestype=to defaults and toconsttoo.
>>> def serve(host='0.0.0.0', port=8080, pool=None):
... return f'{host}:{port}'
>>> parser = cw.mk_parser(serve, config={'pool': cw.HIDE})
>>> [a.dest for a in parser._actions][1:]
['host', 'port']
convention — what the defaults ARE
config is per call; convention is per context — a repo, a house style, the fleet.
It is a frozen dataclass of nine fields, and two values ship.
cw.ARGH is the default, and it reproduces argh 0.31.3 bit-for-bit — footguns
included. That is the point: a repo swaps its dispatcher for cw and its --help does not
move. Concretely, under ARGH:
- a parameter with a default becomes an option, one without becomes a positional;
bool=Truebecomesstore_false(so--loudlyonloudly=Trueturns it off);- an option's help column is
repr(default)unless you give it one; - short flags come from the first character, and if two parameters share one, neither gets
a short flag —
-his always lost to--help; - a
dictreturn value is not iterated; amapobject prints as<map object at 0x...>; **kwargsis silently dropped from the parser.
cw.MODERN is the same grammar with the sharp edges filed off — and it is one keyword:
cw.dispatch(COMMANDS, convention=cw.MODERN)
It makes a parameter positional only when the signature says so (*-keyword-only becomes an
option), resolves type annotations even when you have overridden something, hyphenates group
names, iterates anything iterable that is not a str/bytes/Mapping, and unwraps
Optional[X], Enum and pathlib.Path.
>>> import cw
>>> def counted():
... return map(str, range(2))
>>> cw.dispatch(counted, []) # ARGH: a map is not a list
<map object at 0x...>
0
>>> cw.dispatch(counted, [], convention=cw.MODERN) # MODERN: anything iterable
0
1
0
Every improvement cw has ships as a named convention value that defaults off. There is
no third value and no Convention(...) you are expected to build; if you want one,
dataclasses.replace(cw.ARGH, short_flags=False) is a Convention.
The house idiom is functools.partial, not a cw-specific binder:
import functools, cw
dispatch = functools.partial(cw.dispatch, convention=cw.MODERN, prog="mytool")
One ARGH behaviour worth knowing before it surprises you: under cw.ARGH, any
config entry (or @compat.arg) on a function switches type-annotation inference off for
the whole function — so adding a help string to one parameter can change another
parameter's coercion from int to str. That is argh's rule (can_use_hints = not declared_args), reproduced deliberately; see ADR-0003.
convention=cw.MODERN is the way out.
Escape hatches
Everything above is cw.dispatch, which is the composition of two functions you can use
separately.
Build a parser, run it later. mk_parser is pure — no parsing, no I/O, no side
effects — which is what makes it inspectable from a test:
>>> parser = cw.mk_parser(COMMANDS, prog='tool')
>>> type(parser) is __import__('argparse').ArgumentParser
True
>>> cw.run(parser, ['add', '2', '3'])
5
0
The convention travels with the parser, so convention=cw.MODERN stays one act even
when build and run are two calls. (How that works — one reserved set_defaults key on a
parser that is deliberately not a subclass — is
ADR-0002.)
Bind a function to a parser you built yourself:
>>> import argparse
>>> parser = argparse.ArgumentParser(prog='count')
>>> parser.add_argument('--verbose', action='store_true') and None
>>> def tally(word, *, times=1):
... return [word] * times
>>> _ = cw.set_default_command(parser, tally)
>>> cw.run(parser, ['hi', '-t', '2'])
hi
hi
0
Grow a parser one group at a time. The shape t/priv and i/wads use — a parser
object, then repeated add_commands — is cw.add_commands, and it is the only way to pass
per-group add_subparsers keywords such as title:
>>> parser = cw.mk_parser([], prog='priv')
>>> def status(): "Say how things are."
>>> _ = cw.add_commands(parser, [status], group_name='git_ops',
... group_kwargs={'title': 'Git operations'})
>>> cw.run(parser, ['git_ops', 'status'])
0
cw.mk_parser([], prog=...) is the empty-parser seed. A plain argparse.ArgumentParser()
works too and renders identically — add_commands gives every subparser
cw.ArghHelpFormatter when the parent still carries argparse's stock one, which is exactly
what argh does — but then the root parser's own --help uses argparse's formatter, again
exactly as under argh. Pass formatter_class=cw.ArghHelpFormatter yourself if you want the
root to match too.
dispatch({'archive': {...}}) has no channel for group_kwargs; a group declared that way
gets an empty listing row where argh printed its title. Use mk_parser + add_commands +
run when you need one (#31).
A mapping value must be the commands, not the factory that returns them. A callable value is always a command, because there is no way to tell a zero-argument factory from a command that takes no arguments — so write the parentheses:
>>> def dispatch_funcs():
... return [status]
>>> list(cw.commands_from({'git_ops': dispatch_funcs()})) # a GROUP
['git_ops']
>>> list(cw.commands_from({'git_ops': dispatch_funcs})) # a COMMAND
['git-ops']
Note the hyphen in the second one: {'git_ops': dispatch_funcs} — no parentheses — gives
you a command called git-ops that prints the reprs of the group's members, exit 0.
Capture the output. out= and err= are plain parameters resolved at call time, so a
test can pass a StringIO — something an argh CLI cannot do, because argh binds
output_file=sys.stdout in a signature default:
>>> import io
>>> out = io.StringIO()
>>> cw.dispatch(lines, [], out=out)
0
>>> out.getvalue()
'a\nb\n'
They capture argparse's own --help, usage: and error: output. They do not capture a
command body's own print(), which goes where the process's print goes — as it does under
argh.
Skip the CLI entirely. standalone=False makes the same call an ordinary function call:
nothing printed, nothing caught, the function's own return value:
>>> cw.dispatch(greet, ['world', '--loudly'], standalone=False)
'HELLO world'
Change how results are printed — egress= is one keyword:
>>> cw.dispatch(lambda: {'a': 1}, [], egress=cw.json_egress)
{
"a": 1
}
0
cw.argh_egress (the default), cw.iterable_egress (MODERN's) and cw.json_egress ship;
an egress is any (result, *, out, err) -> int.
Change how types are inferred — decode= is one keyword, taking
(inspect.Parameter, hint) -> add_argument kwargs | callable | None. cw.argh_decode and
cw.modern_decode ship.
Those three — decode=, egress=, convention= — are cw's only seams, and the list of
things that are deliberately not seams is as binding as the list that are. Both are in
ADR-0001.
Shell completion:
pip install 'cw[completion]'
then put # PYTHON_ARGCOMPLETE_OK at the top of your console script. cw.run calls
argcomplete for you when it is installed, and does nothing when it is not.
Migrating from argh
cw replaces argh (LGPL-3.0-or-later) with an MIT package that reproduces its grammar.
There are two steps and you can stop after the first for as long as you like.
Step 1 — one line. cw.compat implements argh's eleven measured names:
-import argh
+from cw import compat as argh
Everything warns once, on first use; CW_COMPAT_QUIET=1 silences it for a repo that has
decided to live here for a while. Declare the dependency as:
dependencies = ["cw>=0.1,<0.2"]
Grep for three import forms before you do it. The one-line change rewrites import argh; it cannot rewrite a name or a submodule somebody imported directly.
| grep for | why it breaks | write instead |
|---|---|---|
from argh import CommandError |
the module still imports argh, and cw will not catch an exception class it has never heard of — CommandError: boom / exit 1 becomes an unhandled traceback. The shim structurally cannot fix this one; it is the single highest-value line on the checklist. |
from cw import CommandError |
from argh.assembling import NameMappingPolicy |
cw.compat is a module, not a package, so there is no cw.compat.assembling |
from cw.compat import NameMappingPolicy |
argh.interaction.confirm |
same reason — there is no interaction namespace |
argh.confirm (i.e. cw.confirm) |
Each of the last two raises an AttributeError naming the replacement, so a missed one is a
startup failure rather than a silent change.
Step 2 — delete the compat import. dispatch_commands(funcs) becomes
cw.dispatch(funcs); @argh.arg(...) decorators become one config dict; argh's
__name__-mutation trick for renaming a command becomes a mapping key.
Prove the migration did nothing. cw.testing records a CLI's behaviour before the change
and replays it after:
# on the old code -- this half imports no cw
python -m cw.testing characterize 'mytool' --cases ./cases.txt -o before.json
# on the new
python -m cw.testing replay before.json --prog 'mytool'
# ... and, when the migration promised --help would not move:
python -m cw.testing replay before.json --prog 'mytool' --strict-help
python -m cw.testing diff-help before.json --prog 'mytool' # read it, do not assert it
replay asserts the exit code and both streams in full for every non---help case, and the
normalised usage: line for a --help one. A --help body that moved is reported as
the non-fatal help-differs — never as identical — because a change of formatter moves
only the help column and the description block and would otherwise be invisible.
--strict-help makes it fatal; diff-help prints it for a human.
The recording half imports no cw — it is one file you can copy into a repo that will
never depend on cw, which is most of them.
Two things to expect, both argh's rules that cw reproduces:
configkeys are spelled the way the command line reads (parse-pth-paths), whileobjkeys and__all__entries are Python identifiers. Adjacent dicts, two spellings. A wrong key is a startup error, not a silent drop.- Under
cw.ARGH, oneconfigentry disables annotation inference for the whole function (above, and ADR-0003).
cw's own CLI
python -m cw specs 'mypkg.cli:main' # what flags would this function get, and why?
python -m cw help 'mypkg.cli:main' # the --help cw would print for it
python -m cw parity # cw's own migration gate, 8 shapes / 137 cases
specs is the one that earns its place day to day — it answers "why did that parameter not
get a short flag?" without building, running or importing anybody's __main__.
Resolving a function from a string
cw.resolution is older than the dispatcher and independent of it: it turns a string
specification into a callable, which is what lets a CLI accept a function as a parameter
value.
>>> from cw import resolve_to_function, parse_ast_spec
>>> resolve_to_function('builtins.len')([1, 2, 3])
3
>>> resolve_to_function('str.upper()', parse_ast_spec)('hello')
'HELLO'
parse_json_spec, parse_ast_spec and parse_spec_with_dot_path are the three spec
grammars; resource_inputs wraps a function so that named parameters are resolved on the
way in. It is the one place cw touches a third-party package, and it is an optional extra:
pip install 'cw[resource]'
Install
pip install cw # the CLI. no dependencies.
pip install 'cw[completion]' # + argcomplete, for shell completion
pip install 'cw[resource]' # + i2, for cw.resource_inputs
pip install 'cw[dev]' # + pytest and argh, to run the differential test suite
Python 3.10+.
Design notes
Six decisions, with their evidence, in docs/adr/:
| ADR-0001 | The three seams, and everything that is deliberately not one |
| ADR-0002 | How a plain ArgumentParser carries its convention and ingress into run |
| ADR-0003 | The four-tier merge ladder is argh's field-specific merge, not dict.update |
| ADR-0004 | group_kwargs, mapping-key naming, MODERN's help column |
| ADR-0005 | Release, pinning and rollback — cw.ARGH is frozen once published |
| ADR-0006 | What v1 does not ship, and where each cut comes back |
Two properties worth stating because they are easy to lose and hard to get back:
cw.mk_parser returns a plain ArgumentParser, and import cw pulls stdlib only —
both are asserted by tests, not by intention.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file cw-0.1.1.tar.gz.
File metadata
- Download URL: cw-0.1.1.tar.gz
- Upload date:
- Size: 198.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.7 {"installer":{"name":"uv","version":"0.12.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1fcde4bf20ac69f884b7be1bed5a5fde471fb2082fe1db5b72e4d911d9cc47bf
|
|
| MD5 |
42f56286dded0da6fe4037acf0d5e824
|
|
| BLAKE2b-256 |
7c3fd443c16422e034d5b00e4c55ba4fe99bc21dd0bb3ca457b506c3faf99dcb
|
File details
Details for the file cw-0.1.1-py3-none-any.whl.
File metadata
- Download URL: cw-0.1.1-py3-none-any.whl
- Upload date:
- Size: 115.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.7 {"installer":{"name":"uv","version":"0.12.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
174cac64788b0938dd6307b1f9c486554167261cea9d90d6427640d1ff7fa12e
|
|
| MD5 |
4998175952558c313bb8038fde431a31
|
|
| BLAKE2b-256 |
3f1aef3872a70081b437f2d9eac5c017d618cbbcd9e2a7bd4753b6e11dbc5b34
|