Turn a dict of arguments into cli commands, ideal companion of docopt-ng.
Project description
commandopt
Turn a dict of arguments into cli commands, ideal companion of docopt-ng.
Why ?
Using the commandopt.commandopt decorator, you are able to declare commands to be
executed depending on the input arguments of your app (required or optional).
It reduces the boilerplate code in your main().
Installation
pip install commandopt
# optionally, the maintained argument parser it pairs with:
pip install docopt-ng
The original docopt has been unmaintained since 2014. docopt-ng is its maintained, drop-in replacement: it installs as
docopt-ngbut still exposes thedocoptmodule, sofrom docopt import docoptkeeps working unchanged. commandopt only relies on the parsed arguments being adict(docopt-ng returns aParsedOptions, which is adictsubclass), so it works with either with no code change.
commandopt does not depend on docopt-ng directly — it accepts any dict of
arguments. Install it explicitly (or via the optional extra commandopt[docopt]).
Signature
def commandopt(mandopts: list[str], opts: list[str] | None = None):
# ...
mandopts: mandatory argument keys — all must be truthy for the command to match.opts: optional argument keys that may also be truthy.
Call
@commandopt(["ship", "new", "<name>"], ["--force"])
def ship_new(arguments):
...
Example usage
#myapp/myapp.py
"""Naval Fate.
Usage:
naval_fate.py ship new <name>...
naval_fate.py new-ship [<name>]
naval_fate.py --version
Options:
--version Show version.
"""
from commandopt import Command
from docopt import docopt
import myapp.commands.ship
if __name__ == '__main__':
arguments = docopt(__doc__, version='Naval Fate 2.0')
# select and execute the matching command in one call:
Command.run(arguments)
# or, to select without executing it:
# command = Command.find(arguments)
# command(arguments)
#myapp/commands/ship.py
from commandopt import commandopt
class ShipCommand:
@commandopt(["ship", "new", "<name>"])
@commandopt(["new-ship"], ["<name>"])
def new(arguments):
"""You can stack the decorator if you want."""
name = arguments["<name>"] or "case when name is empty"
# ... your code here
Command matching
A command is selected by comparing the set of truthy arguments returned by
docopt against the registered command. A command declared with mandatory options
M and optional options O matches an input whose truthy set S satisfies:
M ⊆ S ⊆ M ∪ O
In other words, every mandatory option must be present, and any extra truthy option must be one of the declared optionals. Declaration order is irrelevant (matching is set-based), and each command is stored as a single record — no matter how many optionals it declares.
Earlier versions registered every subset of the optionals (
2**len(O)entries per command). This is no longer the case: a command now costs one registration regardless of the number of optionals, while matching behaves exactly the same.
Registering two different functions whose accepted argument sets overlap
raises a CommandCollisionError instead of silently keeping one of them at
random:
from commandopt import commandopt
from commandopt.exceptions import CommandCollisionError
@commandopt(["status"])
def show_status(arguments):
...
@commandopt(["status"]) # raises CommandCollisionError
def other_status(arguments):
...
Limitations / gotchas
Selection looks at the truthiness of each argument's value, not at the mere presence of its key. docopt returns every declared key on every run (each option, present or not), so commandopt reduces that full dict to the set of keys whose value is truthy — that active subset is what identifies the command:
opts_input = frozenset(opt for opt in arguments if arguments[opt])
Matching is then exact over that set: every truthy argument must be accounted for
by the command's mandatory or optional options, or selection fails with
NoCommandFoundError.
The case to watch for is a global / application-level argument that is truthy on
every invocation — a flag usable with any subcommand, a counter (-vvv → 3),
or an option with a non-False default. It lands in the selection set even though
it isn't meant to identify a command, and breaks matching:
# Usage: tool [-v] add <item>
# tool -v add foo -> {"-v": True, "add": True, "<item>": "foo", "remove": False}
@commandopt(["add", "<item>"]) # -v not declared
def add(arguments): ...
Command.run({"-v": True, "add": True, "<item>": "foo"})
# NoCommandFoundError: {'-v', 'add', '<item>'} is not accepted by {'add', '<item>'}
If the argument genuinely belongs to a command, declare it as an optional so it
falls within M ∪ O:
@commandopt(["add", "<item>"], ["-v"]) # now '-v' is accepted
def add(arguments): ...
For a truly application-wide argument (--config, --debug, -v), declaring it
on every command is noise. Tell commandopt to ignore it for selection — the
matched command still receives it in full:
from commandopt import Command
Command.ignored = {"--config", "--debug", "-v"} # global default
Command.run(arguments) # --config no longer breaks matching
# ...or per call:
Command.run(arguments, ignore={"--config"})
API reference
The public surface is exported via __all__:
@commandopt(mandopts, opts=None, registry=None)— register the decorated function;registrytargets a specificRegistry(defaults to the global one).Command.find(arguments, ignore=())— select and return the matching function without executing it (raisesNoCommandFoundError).Command.run(arguments, ignore=())— select and execute the matching function, returning its result (equivalent tofind(arguments)(arguments)).Command.ignored— a settable set of application-level argument keys excluded from selection (see Limitations / gotchas).Command.list_commands()— return asetofCommandsOpts(opts, f), one per registered command.Command.reset()— clear the global registry (handy for test isolation).CommandsOpts— aNamedTupleof(opts, f)describing one command.
Registry
Command is a thin façade over a process-global default Registry. Instantiate
your own for an isolated set of commands — two CLIs in one process, or clean test
isolation — and pass it to the decorator:
from commandopt import Registry
cli = Registry(ignore={"--config", "--debug"})
@commandopt(["add", "<item>"], registry=cli)
def add(arguments): ...
cli.run(arguments) # same find/run/list_commands/reset API as Command
A Registry exposes add_command, find, run, list_commands, reset, and a
settable ignored set; find/run also accept a per-call ignore.
Exceptions
All commandopt exceptions derive from CommandoptException:
NoCommandFoundError— no command matched. Exposes.opts(the truthy argument keys) and.message.CommandCollisionError— two different functions accept overlapping argument sets. Exposes.opts,.existing,.new, and.message.
from commandopt import Command, NoCommandFoundError
try:
handler = Command.find(arguments)
except NoCommandFoundError as exc:
print("unmatched:", sorted(exc.opts))
Upgrading to 0.6
0.6 reshaped the selection API (full details in the CHANGELOG):
Command.run(arguments)no longer returns the function — it now executes it. UseCommand.find(arguments)to select without running.Command.run(arguments, call=True)→Command.run(arguments).Command.choose_command(...)is removed — useCommand.find(...).Command.add_command(opts=...)→Command.add_command(mandatory=...)(positional callers are unaffected).
Project details
Release history Release notifications | RSS feed
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 commandopt-1.0.0rc1.tar.gz.
File metadata
- Download URL: commandopt-1.0.0rc1.tar.gz
- Upload date:
- Size: 13.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
646b3d331a8be4501f977f011706f1f49000ea8acf5c1f51e2711fc24e2a3fcd
|
|
| MD5 |
8a92ee87b93d71f37679e3e27cd287f3
|
|
| BLAKE2b-256 |
4a9c05038f930cfc49970ef02b35e654651840f6eb32a5b58a02eaa2364df432
|
Provenance
The following attestation bundles were made for commandopt-1.0.0rc1.tar.gz:
Publisher:
release.yml on jaegerbobomb/commandopt
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
commandopt-1.0.0rc1.tar.gz -
Subject digest:
646b3d331a8be4501f977f011706f1f49000ea8acf5c1f51e2711fc24e2a3fcd - Sigstore transparency entry: 2006860185
- Sigstore integration time:
-
Permalink:
jaegerbobomb/commandopt@f800d70de015f0bff7056a8c17ea00d93507ecf3 -
Branch / Tag:
refs/tags/v1.0.0rc1 - Owner: https://github.com/jaegerbobomb
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f800d70de015f0bff7056a8c17ea00d93507ecf3 -
Trigger Event:
push
-
Statement type:
File details
Details for the file commandopt-1.0.0rc1-py3-none-any.whl.
File metadata
- Download URL: commandopt-1.0.0rc1-py3-none-any.whl
- Upload date:
- Size: 9.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2a179eed37bee597f627e3cab8eea90491d1b02cbb0348a7402eb5f285f6bb83
|
|
| MD5 |
2172b85c62363f2d493da7709ab76c1a
|
|
| BLAKE2b-256 |
93d56e8a007f1cb8657e1d76daa43ec728147802831d16f87459f44907f73ec3
|
Provenance
The following attestation bundles were made for commandopt-1.0.0rc1-py3-none-any.whl:
Publisher:
release.yml on jaegerbobomb/commandopt
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
commandopt-1.0.0rc1-py3-none-any.whl -
Subject digest:
2a179eed37bee597f627e3cab8eea90491d1b02cbb0348a7402eb5f285f6bb83 - Sigstore transparency entry: 2006860364
- Sigstore integration time:
-
Permalink:
jaegerbobomb/commandopt@f800d70de015f0bff7056a8c17ea00d93507ecf3 -
Branch / Tag:
refs/tags/v1.0.0rc1 - Owner: https://github.com/jaegerbobomb
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f800d70de015f0bff7056a8c17ea00d93507ecf3 -
Trigger Event:
push
-
Statement type: