Advanced usage
Mixed options
Several options may be mixed by the | operator to target the same handler parameter.
Last seen (on a command line) option wins. If no options from the mix are present, default of the first registered option wins:
Here, the time parameter will be one of --timestamp, --iso , --off options (or None):
def alarm(*, time: int | str | bool | None):
pass
Command(alarm).bind(
time=-(
# this comment is for the nice formatting with black/ruff
option("--timestamp", type=int)
| option("--iso")
| flag("--off", value=False)
)
)
| Code | Command line | Result |
|---|---|---|
option("--pid", type=int, default=1) | option("--prog") |
--pid 123 |
123 |
--prog cat |
"cat" |
|
--pid 123 --prog cat |
"cat" |
|
|
1 |
|
flag("--slow", value=10, default=0) | flag("--fast", value=99) |
--slow |
10 |
--fast |
99 |
|
--fast --slow |
10 |
|
|
0 |
Repeated options are mixed by the + operator. All options go into the resulting list in order of appearance on a command line.
Here, the praises parameter may contain any number of --small, --fast, or --reliable options values:
def sqlite(*, praises: list[int | str]):
pass
Command(sqlite).bind(
praises=-(
#
repeated_option("--small", type=int)
+ repeated_flag("--fast", value="fast")
+ repeated_flag("--reliable", value="yes")
)
)
| Code | Command line | Result |
|---|---|---|
repeated_option("--pid", type=int) + repeated_option("--prog") |
--pid 123 |
[123] |
--prog cat |
["cat"] |
|
--pid 123 --prog cat --prog sudo |
[123, "cat", "sudo"] |
|
|
[] |
|
repeated_flag("--python", value="py") + repeated_flag("--rust", value=False) |
--python --rust --rust |
["py", False, False] |
|
[] |
Help sections
It's common to have --help with options sorted by sections: basic, experimental, reports, logging, etc.
Options are included in sections by a nice subcription syntax.
Or by calling the include() method of section.
cmd = Command(func, add_help=False)
common = cmd.add_section("base options", headline="For every day use")
unknown = cmd.add_section("unknown options", headline="???")
cmd.bind(
quiet=-flag("-q", help="Quiet")[common],
recurse=-flag("-r", help="Recursive")[common],
cage=-flag("--cage", value=4.33)[unknown],
e_or_pi=-(flag("--e", value=2.72) | flag("--pi", value=3.14))[unknown],
)
cmd.parse()
See --help output
usage: prog [-q] [-r] [--cage] [--e] [--pi]
base options:
For every day use
-q Quiet
-r Recursive
unknown options:
???
--cage
--e
--pi
Command.add_section(title, *, headline=None)
Group.add_section(title, *, headline=None)
- title - title for the list of options
- headline - description of the contained options
Mutually exclusive options
Some options could be made mutually exclusive by placing them in the Oneof section.
Options are included in Oneof by a wrapping call, or by oneof's include() method.
Here, --fast and --cheap options are mutually excluse. --http and --https options
are forbidden to use together too:
cmd = Command(func)
choose_one = cmd.add_oneof()
http_or_https = cmd.add_oneof()
cmd.bind(
fast=-choose_one(option("--fast")),
cheap=-choose_one(option("--cheap")),
protocol=-http_or_https(flag("--http", value="http") | flag("--https", value="https"))
)
Command.add_oneof(*, required=False)
Group.add_oneof(*, required=False)
- required - Require at least one option
Warning
Due to the argparse implementation, help sections and oneofs are not orthogonal.
If help sections are used, make sure all options of the concrete Oneof are in the same section.
Required options
Options should be optional, but anyway. The required() function returns the marked-as-required copy of option:
from paramspecli import required
cmd.bind(
password=-required(option("--password")),
)
Deprecating options
Options may be marked as deprecated via the deprecated() function. It return the marked-as-deprecated copy of option.
There would be a warning if option is used on a command line.
Python 3.13+
from paramspecli import deprecated
cmd.bind(
python2=-deprecated(option("--py2")),
)
Path type
paramspecli includes a handy pathlib.Path converter.
class PathConv(type=None, *, exists=None, resolve=True)
- type -
"file","dir"orNone(don't care). Checks path type if appliable. - exists - check the path existence
True: path should existFalse: path should not existNone: don't care
- resolve - resolve to absolute path
There are also a shortcut classmethods for both path types.
Example:
from pathlib import Path
from paramspecli import PathConv
def copy(src: Path, dest: Path):
pass
Command(copy).bind(
-argument("SRC", type=PathConv.file(exists=True)),
-argument("DST", type=PathConv.dir(exists=False))
)
Group options
Groups may behave like an intermediate commands, i.e. have a handler and own parameters.
Such groups are created by instantiating the CallableGroup class
or by calling the Group.add_callable_group() method.
They borrow a func setting and a bind() method from the Command.
One useful application of group options is to perform some global initialization.
Here, the CLI-level --color flag is processed before any choosed command:
def f():
pass
def handle_color(*, use_color: bool):
if use_color:
from colorama import just_fix_windows_console
just_fix_windows_console()
cli = CallableGroup(handle_color)
cli.bind(use_color=-flag("--color"))
with cli.add_command("f", f) as cmd:
cmd.bind()
Each handler parameters are isolated from each other and reside in own namespace.
Groups may pass information down the route in the user-defined context object. Handlers wishing to receive context should bind one of their parameters to the Context() marker.
Here, cli loads app settings from the toml config. Commands may opt-in to access them:
from paramspecli import Context, CallableGroup, PathConv, option
@dataclass
class Settings:
debug: bool
def load_toml(*, ctx: Settings, config: Path | None):
if config:
with config.open("rb") as f:
cfg = tomllib.load(f)
ctx.debug = cfg.get("debug", False)
def f(*, ctx: Settings):
print(ctx.debug)
cli = CallableGroup(load_toml)
cli.bind(
ctx=-Context(),
config=-option("--config", type=PathConv.file(exists=True)),
)
with cli.add_command("f", f) as cmd:
cmd.bind(ctx=-Context[Settings]()) # Note: Context may be specialized for the type-safety
settings = Settings(debug=False)
res = cli.parse(context=settings)
res()
In rare cases when handlers need to access the very parsed route, just include it into the context manually:
from paramspecli import Route
@dataclass
class Ctx:
route: Route
def f(*, ctx: Ctx):
print(ctx.route)
with cli.add_command("f", f) as cmd:
cmd.bind(ctx=-Context[Ctx]())
ctx = Ctx(route=Route([]))
res = cli.parse(context=ctx)
# patching context after the parse
ctx.route = res
res()
Const parameters
The Const class detaches handler parameters from the command line.
This allows to integrate existing functions into the CLI without extra wrappers.
Here, login and frob parameters are internal:
from paramspecli import Const
def third_party_function(login: str, password: str, *, frob: bool, foo: int | None):
pass
with cli.add_command("frobnicate", third_party_function) as cmd:
cmd.bind(
-Const("bob"),
-argument("PASSWORD"),
frob=-Const(True),
foo=-option("--foo", type=int),
)
In such cases, it may be useful to deviate from 'positionals are arguments' convention and bind positionals to options by name.
The login parameter is an option here:
with cli.add_command("frobnicate", third_party_function) as cmd:
cmd.bind(
login=-option("--login", default="bob"),
...
)
Actions
Actions are options with side effects. They are not bound to the handlers. Actions are attached via Group.append_action or Command.append_action
methods. Two common actions are included: version_action and help_action.
Supporting --version action:
from paramspecli import version_action
cli = Group()
cli.append_action(version_action("42"))
cli.parse()()
$ python prog.py --version
42
See Extending on defining custom actions.
Lazy imports
Typically, only one of the imported handlers is ever invoked by the CLI. Some import-heavy handlers
may be lazy imported with the help of the resolve_later() wrapper.
The startup time reduction and memory savings could be significant.
Here, heavy.py file would be loaded only then heavy_handler() is actually used.
import sqlalchemy
import matplotlib
import requests
import pandas
def handler(*, data: str | None):
pass
from paramspecli.util import resolve_later
def heavy_handler():
from . import heavy
return heavy.handler
cmd = Command(resolve_later(heavy_handler))
cmd.bind(data=-option("--data"))
Warning
The heavy_heandler() is type safe with pyright, which infers it's return type just right.
mypy and ty infers it as Any/Unknown. Of course, it could be copypaste-annotated,
but it kind of misses the point.
Configuration
A few aspects of the generated ArgumentParser may be tuned by passing a custom Config to the Group.parse() method.
Config is a dataclass with a following fields:
-
show_default =
True. Defaults are printed in help where it makes sence. Set toFalseto globally opt-out. Options may set own show_default to selectively opt-in. -
propage_epilog =
False. Set it to theTrueto force groups and commands show the same epilog as the root group. It's a simple way to ensure epilogs are consistent.
-
catch_typeconv_exceptions =
False. By default, argparse recognizes only a few exceptions while type converting:ValueError,TypeErrorandArgumentTypeError. Other exceptions bubble up to the program and produce ugly call traces. SetTrueto catch all type converter exceptions and report them as nice CLI errors.Tip
Alternatively, there is a catch_all decorator to wrap type converters:
from paramspecli.util import catch_all @catch_all def to_ip(s: str): return IPv4Address(s) cmd.bind(-argument("IP", type=to_ip)) -
allow_abbrev = False. Allow argparse do the guesswork and accept sloppy command line.
- parser_class. Allows to use alternative parser class instead of the
ArgumentParser - formatter_class - Allows to choose
HelpFormatter-compatible formatter. By default, set to a slighty modified one, which disables wrapping if there are manual line breaks in text. - root_parser_extra_kwargs - dict of extra kwargs for the root
ArgumentParser() - sub_parser_extra_kwargs - dict of extra kwargs for every sub
ArgumentParser()