mrkeyoor.com_
Sat 08 Aug 20:58 UTC
PyPICLI & Toolingupdated 08 Aug 2026

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.

Verdict

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.

API stability4/5Command, Parser, Flag, middleware injection, and the testing model have established shapes, and the project uses clear calendar versions and a changelog. Release 26.0.1 did introduce a real support boundary by dropping Python 3.8 and 3.9, added CommandGroup, migrated the build backend, and modernized signatures with type hints and keyword-only arguments. The core is stable, but yearly releases can contain platform and construction changes worth reading.
Docs5/5The Read the Docs site now has separate guides for Command, Parser, flags, positional arguments, middleware, errors, I/O, testing, and design choices. It documents subtle behavior such as multi='extend' always producing a list, strict argument ordering, built-in injectable names, prepare versus run validation, SystemExit-based errors, and CommandChecker isolation. The tutorial still has unfinished later sections, but the reference pages compensate well.
Maintenance5/5Version 26.0.1 was published on 2026-06-17, the repository was pushed on 2026-08-03, and only 7 issues and pull requests are open. The latest release added Python 3.14, modernized CI with uv and tox-uv, moved packaging to Flit, added OIDC publishing, expanded documentation, fixed Windows behavior, and added grouped subcommands. That is current, concrete maintenance rather than metadata-only activity.
Ecosystem3/5The package records 5,511,542 weekly downloads and its README identifies real users including glom, Pocket Protector, and Montage administration tools. Still, the repository has only 24 stars, one runtime dependency, and no large catalogue of extensions or integrations. Face provides its own help, middleware, prompts, and test runner, but hiring familiarity and reusable plugins are much stronger around Click and Typer.

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
Skip it if

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 != 0

CommandChecker restores environment, working directory, and streams after each run. Application globals and other process state are still shared between tests.

Alternatives

PackageRegistryPick it when
clickPyPIUse it for the most established decorator-based Python CLI ecosystem and broad third-party familiarity
typerPyPIUse it when Python type annotations should define options, arguments, validation, and editor help
cycloptsPyPIUse it for annotation-driven command models with modern typing support and less decorator ceremony