face
Face is a Python framework for parsing command lines and dispatching plain handler functions. Flags and parsed positional arguments are injected by matching their normalized names to handler parameters; Commands add generated help, nested subcommands, middleware, dependency checks, and in-process testing on top of a lower-level Parser. Release 26.0.1 uses calendar versioning, requires Python 3.10 or newer, and adds grouped subcommand display for larger CLIs.
Face is a thoughtful choice for Python CLIs that benefit from plain handlers, strict syntax, middleware-provided resources, and fast in-process tests. Choose a more conventional framework when contributor familiarity, annotation-driven configuration, or permissive argument ordering matters more than its dependency-injection model.
Use it if
- You want CLI handlers to remain plain Python functions while parsed flags arrive as named parameters
- Your command needs middleware that can provide typed resources such as clients or sessions to selected handlers
- You want nested subcommands, generated help, strict parsing, and in-process stdout, stderr, and exit-code tests
- You prefer programmatic command construction over decorator-heavy Click code or docstring-driven parsing
- You support Python 3.9 or older: 26.0.1 requires Python 3.10 and explicitly dropped 3.8 and 3.9
- You want type annotations to define the CLI automatically: Face uses explicit Flag, PosArgSpec, ChoicesParam, and ListParam objects, while Typer and Cyclopts derive more behavior from signatures and annotations
- Your users expect permissive GNU-style argument ordering: Face intentionally stops flag parsing at the first positional argument, disallows positional arguments on non-leaf commands, and limits a flag to one whitespace-separated value
- You dislike name-based dependency injection: renaming --output-file to another normalized name or changing a handler parameter can break preparation until the flag, middleware provider, and function signature agree
- You need the largest extension and documentation ecosystem: Face has 24 GitHub stars and a few named users, while Click and Typer are the more familiar defaults for teams and contributors
Setup reality
Install with pip install face on Python 3.10 or newer; boltons is its one declared runtime dependency. Face does not create a console command for your project. Put command construction in a cli.py module, expose a zero-argument main function that calls Command.run(), and wire that function under [project.scripts] in pyproject.toml. The handler signature is configuration: --output-file becomes output_file, built-in injected values use trailing underscores such as posargs_, flags_, args_, and subcommands_, and a parameter Face cannot provide raises NameError during prepare or run. Call cmd.prepare() after building the full tree because run validates only the selected subcommand path. Flags parse as strings unless parse_as is set; missing=ERROR makes a flag required, while multi='extend' always injects a list, including [] when absent. Face is deliberately strict about order: options come before positional arguments, and values meant to follow -- require post_posargs configuration. Hidden --flagfile support is enabled by default and can recursively read one flag per line from local files; pass flagfile=False when that input channel is unnecessary or inappropriate for the command. CommandLineError and UsageError also inherit SystemExit, so embedding a Command inside another Python service requires deliberate exception handling. Middleware must have a first parameter named next_, must call it to continue, and must declare provided values. For tests, CommandChecker runs in-process and temporarily changes environment, cwd, streams, and stdin; it is fast, but global application state still belongs to the test process.
Patterns
Dispatch a plain handler functionbuild-basic-command
from face import Command, echo
def greet():
echo('hello')
def main():
Command(greet, name='greet', doc='Print a greeting').run()Expose main through your package's console-script configuration. Command.run defaults to sys.argv when argv is not supplied.
Inject parsed flags by parameter nameadd-typed-flags
from face import Command
def serve(host, port, verbose):
if verbose:
print(f'serving on {host}:{port}')
cmd = Command(serve)
cmd.add('--host', missing='127.0.0.1')
cmd.add('--port', parse_as=int, missing=8080)
cmd.add('--verbose', char='-v', parse_as=True, missing=False)Flag names are normalized, so --output-file maps to output_file. Face checks that every required handler parameter can be supplied.
Mark a flag as requiredrequire-flag
from face import Command, ERROR
def deploy(target):
print(f'deploying to {target}')
cmd = Command(deploy)
cmd.add('--target', missing=ERROR, doc='deployment target')The docs call required flags a CLI usability smell. Prefer a positional argument or sensible default when either describes the command better.
Accept repeated and comma-separated valuescollect-multiple-values
from face import Command, ListParam
def tag(labels, ports):
print(labels, ports)
cmd = Command(tag)
cmd.add('--label', multi='extend')
cmd.add('--ports', parse_as=ListParam(parse_one_as=int))multi='extend' injects [] when absent and a list for one or more uses. ListParam handles one flag value such as --ports 80,443.
Limit a flag to known choicesrestrict-choices
from face import ChoicesParam, Command
def deploy(environment):
print(environment)
cmd = Command(deploy)
cmd.add(
'--environment',
parse_as=ChoicesParam(['dev', 'staging', 'prod']),
missing='dev',
)ChoicesParam infers its parsing type from the first choice unless configured otherwise. Invalid values become argument-parse errors before the handler runs.
Inject bounded typed positional argumentsparse-positional-values
from face import Command, PosArgSpec
def total(numbers):
print(sum(numbers))
cmd = Command(
total,
posargs=PosArgSpec(
parse_as=int, min_count=1, max_count=5, name='numbers'
),
)A named PosArgSpec injects the parsed values under that name. With exactly one allowed value, Face unwraps it instead of passing a tuple.
Build a command with subcommandsadd-subcommands
from face import Command
def add_user(name):
print('add', name)
def delete_user(name):
print('delete', name)
root = Command(None, name='users')
add_cmd = root.add(add_user, name='add')
add_cmd.add('--name')
delete_cmd = root.add(delete_user, name='delete')
delete_cmd.add('--name')A root with no handler relies on help or a selected subcommand. Subcommands can nest, but non-leaf commands cannot accept positional arguments.
Group related commands in help outputgroup-subcommands
from face import Command, CommandGroup
admin = CommandGroup('Administration')
admin.add(create_user, name='create-user')
admin.add(delete_user, name='delete-user')
root = Command(None, name='app')
root.add(admin)CommandGroup was added in 26.0.1. It only changes help organization; grouped commands remain direct siblings in the command path.
Inject a resource from middlewareprovide-middleware-value
from face import Command, face_middleware
@face_middleware(provides=['client'])
def client_middleware(next_):
client = make_client()
try:
return next_(client=client)
finally:
client.close()
def sync(client):
client.sync()
cmd = Command(sync)
cmd.add(client_middleware)The first middleware parameter must be named next_. Returning without calling next_ stops downstream middleware and the command handler.
Validate every dependency before runtimevalidate-command-tree
root = build_command_tree()
root.prepare()
root.run()run prepares only the selected command path. Calling prepare with no paths catches missing flag, builtin, or middleware providers across all subcommands at startup.
Return a clean user-facing usage failurereport-usage-error
from face import UsageError
def deploy(target):
if '/' in target:
raise UsageError('--target must not contain slashes')
perform_deploy(target)UsageError inherits SystemExit and normally exits with code 1 after printing to stderr. Do not use it for unexpected internal failures.
Capture CLI output without a subprocesstest-command-in-process
from face import CommandChecker
checker = CommandChecker(cmd, env={'APP_MODE': 'test'})
result = checker.run('serve --port 9000 --verbose')
assert result.exit_code == 0
assert '9000' in result.stdout
failed = checker.fail('serve --port nope')
assert failed.exit_code != 0CommandChecker restores environment, working directory, and streams after each run. Application globals and other process state are still shared between tests.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| click | PyPI | Use it for the most established decorator-based Python CLI ecosystem and broad third-party familiarity |
| typer | PyPI | Use it when Python type annotations should define options, arguments, validation, and editor help |
| cyclopts | PyPI | Use it for annotation-driven command models with modern typing support and less decorator ceremony |