Skip to main content

An explicit, zero-dependency CLI framework for Python

Project description

excli

An explicit, zero-dependency CLI framework for Python.

excli makes you declare everything -- every command, flag, argument, and environment variable must have help text or the framework errors at registration time. Types are str and bool only; there is no magic type inference. Environment variables are first-class, with prefix enforcement to keep your config namespace clean.

Installation

uv add excli

Or:

pip install excli

Requires Python 3.11+.

Quickstart

# greet.py
import excli

app = excli.App(name="greet", version="0.1.0", help="a friendly greeter")

@app.command("hello", help="say hello", args=[excli.Arg(name="name", help="who to greet")])
@excli.flag("loud", short="l", type=bool, help="shout the greeting")
def hello(name, loud):
    msg = f"Hello, {name}!"
    if loud:
        msg = msg.upper()
    print(msg)

app.run()
$ python greet.py hello World
Hello, World!

$ python greet.py hello --loud World
HELLO, WORLD!

$ python greet.py hello --help
greet hello -- say hello

Arguments:
  name    who to greet

Flags:
  --loud, --no-loud, -l    shout the greeting [default: false]

Commands and Groups

Register top-level commands with @app.command:

app = excli.App(name="myapp", version="1.0.0", help="manage deployments")

@app.command("status", help="show current status")
def status():
    print("all systems go")

Create groups for two-level nesting with app.group:

db = app.group("db", help="manage databases")

@db.command("migrate", help="run database migrations")
@excli.flag("dry-run", type=bool, help="preview without applying")
def migrate(dry_run):
    if dry_run:
        print("would run migrations")
    else:
        print("running migrations")

@db.command("seed", help="populate with sample data")
@excli.flag("count", type=str, help="number of records", default="100")
def seed(count):
    print(f"seeding {count} records")
$ myapp db migrate --dry-run
would run migrations

$ myapp db seed --count 500
seeding 500 records

Flags

Declare flags with the @excli.flag decorator. Every flag must have help text.

String flags

@app.command("build", help="build the project")
@excli.flag("output", short="o", type=str, help="output directory", default="dist")
def build(output):
    print(f"building to {output}")

String flags accept values via --output dist or --output=dist. A string flag without a default is required.

Bool flags

@app.command("deploy", help="deploy the app")
@excli.flag("force", short="f", type=bool, help="skip confirmation")
def deploy(force):
    if force:
        print("deploying without confirmation")

Bool flags default to False. Pass --force to set True, or --no-force to explicitly set False. The --no- negation form is available by default for all bool flags; disable it with negatable=False.

Short aliases

Any flag can have a one-character short alias:

@excli.flag("verbose", short="v", type=bool, help="verbose output")

This allows both --verbose and -v.

Required vs optional

  • str flags with no default are required -- the parser errors if missing.
  • str flags with a default are optional.
  • bool flags always default to False.

Arguments

Positional arguments are declared with excli.Arg. There are two equivalent forms.

Using the args= parameter:

@app.command("copy", help="copy files", args=[
    excli.Arg(name="src", help="source path"),
    excli.Arg(name="dst", help="destination path"),
])
def copy(src, dst):
    print(f"copying {src} to {dst}")

Using the @excli.arg decorator:

@app.command("show", help="show a file")
@excli.arg("path", help="file to show")
def show(path):
    print(f"showing {path}")

Arguments are matched in order. Use required=False for optional arguments. The -- separator stops flag parsing, so everything after it becomes positional:

$ myapp cmd -- --not-a-flag

Environment Variables

Flags can be backed by environment variables with the env parameter:

app = excli.App(name="myapp", version="1.0.0", help="my app", env_prefix="MYAPP")

@app.command("deploy", help="deploy the app")
@excli.flag("region", type=str, help="cloud region", env="MYAPP_REGION", default="us-east-1")
def deploy(region):
    print(f"deploying to {region}")

Prefix enforcement

When env_prefix is set on the App, all env vars must start with that prefix. This is validated at registration time:

# This raises ValueError: env var 'REGION' must start with 'MYAPP_'
@excli.flag("region", type=str, help="region", env="REGION", default="x")

External env vars

Use prefixed=False for env vars outside your app's namespace:

@excli.flag("token", type=str, help="auth token", env="GITHUB_TOKEN", prefixed=False, default="")

Priority

Values resolve in this order: CLI argument > environment variable > default. If none of the three provides a value, the parser errors.

Bool env vars

Bool flags from env vars accept 1, true, yes (case-insensitive) for True and 0, false, no for False. Any other value is an error.

Tags

Tags are reusable bundles of flags that can be applied to multiple commands:

auth_tag = excli.Tag(
    name="auth",
    flags=[
        excli.Flag(name="token", type=str, help="auth token", env="MYAPP_TOKEN", default=""),
        excli.Flag(name="insecure", type=bool, help="skip TLS verification"),
    ],
)

@app.command("deploy", help="deploy the app", tags=[auth_tag])
def deploy(token, insecure):
    print(f"token={'set' if token else 'unset'}, insecure={insecure}")

@app.command("status", help="check status", tags=[auth_tag])
def status(token, insecure):
    print(f"checking status")

Both commands now have --token and --insecure flags. Tag flags appear in help output and are parsed like any other flag.

Help Output

Help is auto-generated at three levels. Pass --help or -h at any level, or invoke the app with no arguments.

App level (myapp --help):

myapp v1.0.0 -- manage deployments

Commands:
  deploy    deploy the application

Groups:
  db    manage databases

Use 'myapp <command> --help' for more information.

Group level (myapp db --help):

myapp db -- manage databases

Commands:
  migrate    run database migrations
  seed       populate with sample data

Use 'myapp db <command> --help' for more information.

Command level (myapp deploy --help):

myapp deploy -- deploy the application

Arguments:
  target    deployment target

Flags:
  --region, -r <str>         cloud region [env: MYAPP_REGION] [default: us-east-1]
  --force, --no-force, -f    skip confirmation prompt [default: false]

Version: --version or -v prints myapp 1.0.0.

Testing

app.test(argv) runs the CLI in-process and returns a Result with captured output:

result = app.test(["deploy", "--force", "production"])

assert result.exit_code == 0
assert "deploying" in result.stdout
assert result.stderr == ""

The Result dataclass has three fields: stdout, stderr, and exit_code.

Explicit by Design

excli is opinionated about explicitness:

  • Help is mandatory. Every command, flag, and argument must have help text. Missing help raises ValueError at registration time, not at runtime.
  • Only str and bool. No int, float, or list types. Parse them yourself in the handler -- it is one line of code and makes the conversion visible.
  • Handler signatures are validated. Every declared flag and arg must have a matching parameter in the handler function, and vice versa. Extra or missing parameters raise ValueError.
  • Env var prefixes are enforced. If you set env_prefix="MYAPP", every env-backed flag must use that prefix (or explicitly opt out with prefixed=False).
  • No hidden defaults. Required flags fail loudly. Bool flags default to False. Everything else must be declared.

If you want automatic type coercion, subcommand hierarchies deeper than two levels, or rich terminal formatting, consider argparse, click, or typer.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

excli-0.1.0.tar.gz (21.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

excli-0.1.0-py3-none-any.whl (10.6 kB view details)

Uploaded Python 3

File details

Details for the file excli-0.1.0.tar.gz.

File metadata

  • Download URL: excli-0.1.0.tar.gz
  • Upload date:
  • Size: 21.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for excli-0.1.0.tar.gz
Algorithm Hash digest
SHA256 867d85e5d30d97b9591c384f949cb4e15168c62046082ffdf9f6fa205b45f3f2
MD5 4c2c74a40c2180d5847b5cdf722dbfcb
BLAKE2b-256 ae8519e0c854e6fe04593eec14dfe6cb6957e171aceb96b9f9f0dbdd8333a9c8

See more details on using hashes here.

Provenance

The following attestation bundles were made for excli-0.1.0.tar.gz:

Publisher: publish.yml on smm-h/excli

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file excli-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: excli-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 10.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for excli-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 dec63353956612ee1973712ce022c0ae51813ad357d500741d953ebc7c20eaf5
MD5 54a2f1c58f1e12917b0a5b9df0038255
BLAKE2b-256 8d8cf5159bedc8234cbcb6bce2e661ea683f7e65db21ad7ef23734b4fbf227fc

See more details on using hashes here.

Provenance

The following attestation bundles were made for excli-0.1.0-py3-none-any.whl:

Publisher: publish.yml on smm-h/excli

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page