Skip to main content

Turn a function signature into a command-line script

Project description

defcmd

Turn any Python function signature into a command-line script.

โ€œI thought Python was supposed to be the scripting language! why do I have to write 100 lines of boilerplate with argparse just to make a simple script?!โ€
โ€” Me, before writing 2000 lines of "minimal" cli framework

from defcmd import cmd

@cmd
def greet(name: str, times: int = 1, excited: bool = False):
    """Greet a person a specified number of times, optionally with excitement."""
    greeting = f"Hello, {name}!"
    if excited:
        greeting = greeting.upper() + "!!!"
    for _ in range(times):
        print(greeting)

if __name__ == "__main__":
    greet.run()

This will create a command-line interface that allows you to call the greet function with the appropriate arguments. For example:

python greet.py Alice --times 3 --excited

defcmd scripts support interactive mode as well, allowing you to call the function without providing any arguments upfront. You can simply run the script and it will prompt you for the inputs:

$ python greet.py
Name: Alice
Times [1]:   # Leave blank for default value
Excited [y/N]: y
HELLO, ALICE!!!

[!TIP] Use --help for full usage information as provided by argparse.

Zero external dependencies. Built on argparse and inspect from the standard library.

Takes inspiration from PowerShell (of all places!) and the fncmd crate that I stumbled across when trying something similar in Rust.

The goal is to make it easy to write small scripts that are usable from the command line without having to write a lot of boilerplate.

[!NOTE] defcmd is not supposed to be a full-featured CLI framework like click or typer. It is intentionally minimal, and is designed for small scripts where you want to avoid boilerplate and just get the job done. At least, that was the goal when I began; I may have gotten a bit carried away.


๐Ÿ“– Documentation

Defining a command

Any plain function becomes a CLI when decorated with @cmd:

from defcmd import cmd

@cmd
def greet(name: str, times: int = 1, excited: bool = False):
    """Greet a person a specified number of times, optionally with excitement."""
    # ... Your Logic Here ...

if __name__ == "__main__":
    greet.run()

defcmd derives everything (argument names, types, defaults, required-ness) directly from the function signature. There's no separate schema to keep in sync.

@cmd parameters

Parameter Type Default Description
description str | None fn.__doc__ Override the help description for --help.
help str | None description โ†’ fn.__doc__ Short help text for subcommand listings.
examples dict[str, str] | None None Usage examples shown in --help via --examples flag.
epilog str | None None Text displayed after argument help in --help.
version str | None None Adds --version / -v flag that prints and exits.
hidden bool False Exclude from interactive command selection.
prompt_optional bool | None True Skip prompting for optional parameters in interactive mode.
add_examples_flag bool True Set to False to hide the --examples flag.
add_color_flag bool True Set to False to hide the --color / --no-color flags.
@cmd(description="Deploy the app", version="1.0.0", hidden=True)
def deploy(host: str, port: int = 8080):
    ...

Usage examples

Pass a dict[str, str] of {description: command} to show usage examples in --help:

@cmd(
    examples={
        "Add two numbers": "calc 1 + 2",
        "Multiply": "calc 2 '*' 3",
    },
    epilog="See the full docs at https://example.com/calc",
)
def calc(a: float, op: str, b: float):
    ...
$ python calc.py --help
usage: calc.py [-h] [--examples] a op b

positional arguments:
  a
  op
  b

options:
  -h, --help    show this help message and exit
  --examples    Show usage examples and exit

examples:
  calc 1 + 2   # Add two numbers
  calc 2 * 3   # Multiply

See the full docs at https://example.com/calc

The --examples flag prints only the example lines and exits, without the rest of the help text:

$ python calc.py --examples
calc 1 + 2  # Add two numbers
calc 2 * 3  # Multiply

Set add_examples_flag=False to suppress the --examples flag while keeping examples in --help.

Required vs. optional parameters

A parameter with no default value is required and becomes a positional argument:

def greet(name: str):
    ...
python greet.py Alice

A parameter with a default value becomes an optional --flag:

def greet(name: str, times: int = 1):
    ...
python greet.py Alice --times 3

Boolean flags

bool parameters are always flags, never positionals, even if required. They support both the on and off form:

def greet(name: str, excited: bool = False):
    ...
python greet.py Alice --excited
python greet.py Alice --no-excited

Choices with Literal

A typing.Literal[...] annotation restricts the value to a fixed set of choices, enforced by argparse:

from typing import Literal

def deploy(env: Literal["dev", "staging", "prod"] = "dev"):
    ...
python deploy.py --env staging
python deploy.py --env nope   # error: invalid choice

Path arguments

pathlib.Path annotations are supported natively. Paths are auto-expanded (~) and resolved to absolute paths:

from pathlib import Path

def process(data: Path, output: Path = Path("out.txt")):
    ...
python script.py ~/input.csv --output ./results.csv
# Path becomes: /home/user/input.csv
# Output becomes: /cwd/results.csv

Set Spec(path_resolve=False) to preserve the raw path as provided.

Add validation with Spec:

from typing import Annotated
from defcmd.spec import Spec

def read_log(
    log: Annotated[Path, Spec(path_exists=True, path_type="file")],
    out_dir: Annotated[Path, Spec(path_type="dir")] = Path("./logs"),
):
    ...

Interactive mode

Run the script with no arguments at all, from a real terminal, and defcmd walks through every parameter, prompting for each one in order:

$ python greet.py
? Name: Alice
โœ“ Name: Alice
? Times [default: 1]:
โœ“ Times: 1
? Excited [y/N]: y
โœ“ Excited: y
HELLO, ALICE!!!
  • Required parameters keep re-prompting until you enter something; blank input isn't accepted.
  • Optional parameters show their default in brackets; pressing Enter accepts it.
  • Boolean parameters accept a single keypress (y or n) or press Enter to accept the default.
  • Each prompt is replaced in-place by a โœ“ confirmation line on completion.
  • Literal parameters list the valid choices and accept either the exact value or its number.

When using the advanced Spec annotations:

  • Parameters with Spec(prompt="Enter value") override the default prompt text.
  • Parameters with Spec(prompt=True) force prompting even when @cmd(prompt_optional=False) is set.
  • Parameters with Spec(prompt=False) skip prompting entirely and use the default value (raises an error if no default exists).
  • Parameters with Spec(secret=True) hide the input (for passwords, tokens, etc.).
  • Parameters with Spec(help=...) can override the default help text shown in --help.
  • Parameters with validation constraints (min, max, pattern) will re-prompt until the value satisfies the constraint.

Interactive mode only triggers when both of these are true:

  • no arguments were passed,
  • and stdin is a real terminal (not piped or redirected).

Running a script with no args in a non-interactive context (cron, CI, piped input) just errors normally instead of hanging waiting for input.

Help text

The function's docstring is shown as the description in --help. Override it with @cmd(description=...):

@cmd(description="Deploy the application to a target host.")
def deploy(host: str):
    ...
$ python deploy.py --help
usage: deploy.py [-h] host

Deploy the application to a target host.

positional arguments:
  host

Use @cmd(epilog=...) for text after the argument help, and @cmd(help=...) for a shorter description used in subcommand listings. Per-parameter help text is set via Annotated[..., Spec(help=...)] โ€” see the next section.

Advanced Parameter Specification

The Spec annotations allow you to attach additional metadata to the cli parameters:

from typing import Annotated, Literal
from defcmd import cmd
from defcmd.spec import Spec

@cmd
def action(
        payload: Annotated[Literal["json", "xml", "csv", "html"], Spec(help="The payload to send", prompt="Payload")],
        token: Annotated[str, Spec(short="t", help="The authentication token", prompt="Enter your token", secret=True)],
        host: Annotated[str, Spec(help="The host to connect to")] = "localhost",
        port: Annotated[int, Spec(short='p', min=1, max=65535, help="The port to connect to")] = 8080,
        verbose: Annotated[bool, Spec(short='v', help="Enable verbose output")] = False
    ):
    # ... Your Logic Here ...

if __name__ == "__main__":
    action.run()
$ python action.py -p 8080 -v
Payload: Hello World
Enter your token: ********
# ... Your Logic Here ...

Specification Options

Spec Description Overrides
short Short flag for the parameter (e.g., -p)
help Help text for the parameter, shown in --help The default help message
prompt Custom prompt text, True to force, False to skip The default prompt text
secret If True, input is hidden in interactive mode
env Environment variable(s) to read a default value from The function's default value
min Minimum numeric value (inclusive)
max Maximum numeric value (inclusive)
pattern A regex pattern the value must match (uses fullmatch)
validate A custom validation function for the parameter
path_exists If True, raises an error if the path doesn't exist
path_type "file" or "dir" - validates the path type
path_resolve If True (default), expands ~ and resolves to absolute path

Subcommands

Multiple commands can be grouped together using CLI:

from defcmd import CLI

cli = CLI(description="Project management tool")

@cli.subcmd
def init(name: str):
    """Initialize a new project"""
    print(f"Project '{name}' created")

@cli.subcmd(name="build-all")
def build(clean: bool = False):
    """Build the project"""
    print("Building... (clean={clean})")

@cli.subcmd(description="Deploy to production (overrides docstring)")
def deploy(env: str = "prod"):
    """Deploy the project"""
    print(f"Deploying to {env}")

if __name__ == "__main__":
    cli.run()
$ python project.py --help
usage: project.py [-h] {init,build-all,deploy} ...

Project management tool

positional arguments:
  {init,build-all,deploy}
    init                 Initialize a new project
    build-all            Build the project
    deploy               Deploy to production (overrides docstring)

$ python project.py init myapp
Project 'myapp' created

$ python project.py deploy --env staging
Deploying to staging

Run with no arguments in a terminal to pick a command interactively:

$ python project.py
Available commands:
  init
  build-all
  deploy
Enter a command: init
Name: myapp
Project 'myapp' created

The @cli.subcmd decorator accepts all @cmd parameters plus:

Parameter Type Default Description
name str | None fn.__name__ Override the subcommand name.
aliases list[str] | None None Alternative names for the subcommand (add_parser(aliases=...)).

CLI(...) also accepts description, help, version, examples, add_examples_flag, and add_color_flag for the top-level CLI parser.

Nested subcommands / groups

Use cli.group() to create command groups with nested subcommands:

cli = CLI(description="Cloud tool")

# Create a group and register subcommands on it
db = cli.group("db", description="Database commands")

@db.subcmd
def migrate(message: str):
    """Run database migrations"""
    print(f"Running migration: {message}")

@db.subcmd
def seed(count: int = 10):
    """Seed the database"""
    print(f"Seeding with {count} records")

if __name__ == "__main__":
    cli.run()
$ python tool.py --help
usage: tool.py [-h] {db} ...

Cloud tool

positional arguments:
  {db}
    db         Database commands

$ python tool.py db --help
usage: tool.py db [-h] {migrate,seed} ...

Database commands

positional arguments:
  {migrate,seed}
    migrate      Run database migrations
    seed         Seed the database

Groups can be nested arbitrarily deep:

container = cli.group("container", description="Manage containers")

@container.subcmd
def run(image: str, detach: bool = False):
    """Run a container"""
    print(f"Running {image}")

logs = container.group("logs", description="View container logs")

@logs.subcmd
def follow(name: str):
    """Follow log output"""
    print(f"Following {name}")
$ python tool.py container run nginx --detach
$ python tool.py container logs follow my_container

Interactive mode works with groups, it recursively prompts until it reaches a leaf command, then runs its wizard.

Environment Variables

Environment variables provide default values that can be overridden by CLI arguments:

from defcmd import cmd
from defcmd.spec import Spec
from typing import Annotated

@cmd
def deploy(
    host: Annotated[str, Spec(env="HOST")],
    port: Annotated[int, Spec(env=("PORT", "APP_PORT"))] = 8080,
):
    ...
$ export HOST=example.com
$ python deploy.py             # uses HOST env var
$ python deploy.py other.com   # CLI arg overrides env

If the env var is not set, the function's default (or a required positional error) applies. A tuple tries each variable name in order and uses the first one found. CLI arguments always take precedence over env vars.

ANSI Color Output

defcmd supports colored terminal output automatically:

  • $NO_COLOR: If the NO_COLOR environment variable is set, ANSI escape sequences are disabled.
  • TTY detection: If stdout is piped or redirected (not a terminal), colors are disabled automatically.
  • --color / --no-color: Both Cmd and CLI register these flags to override auto-detection. Pass add_color_flag=False to suppress them.
$ python script.py --color       # force color on
$ python script.py --no-color    # force color off

The --color flag works at any level; pass it before or after the subcommand:

$ python tool.py --color db migrate "init"
$ python tool.py db migrate --no-color "init"

Set add_color_flag=False on a command or CLI to hide the --color / --no-color flags from --help:

@cmd(add_color_flag=False)
def greet(name: str):
    ...

What's not supported (yet)

  • *args and **kwargs in the decorated function's signature raises an error at decoration time.

๐Ÿšง Development

This is a uv project. uv manages the virtual environment, dependencies, and the lockfile (uv.lock).

๐Ÿ’ฝ Setup

Clone the repo and sync dependencies (this creates a .venv and installs everything needed, including dev dependencies like pytest):

git clone https://github.com/Shresht7/defcmd.git
cd defcmd
uv sync

๐Ÿ—๏ธ Project structure

./
โ”œโ”€โ”€ examples/
โ”‚   โ”œโ”€โ”€ 00_basic.py
โ”‚   โ”œโ”€โ”€ 01_annotated.py
โ”‚   โ”œโ”€โ”€ 02_subcommands.py
โ”‚   โ”œโ”€โ”€ 03_nested_subcommands.py
โ”‚   โ””โ”€โ”€ 04_examples.py
โ”œโ”€โ”€ src/
โ”‚   โ””โ”€โ”€ defcmd/
โ”‚       โ”œโ”€โ”€ __init__.py
โ”‚       โ”œโ”€โ”€ terminal/           # Terminal Library
โ”‚       โ”œโ”€โ”€ widgets/            # Interactive Widgets
โ”‚       โ”œโ”€โ”€ argparser.py        # Argument Parser
โ”‚       โ”œโ”€โ”€ convert.py          # Type Conversion & Validation
โ”‚       โ”œโ”€โ”€ interactive.py      # Interactive Prompting Wizard
โ”‚       โ”œโ”€โ”€ introspect.py       # Function Signature Introspection
โ”‚       โ”œโ”€โ”€ runner.py           # Command & CLI Runner
โ”‚       โ””โ”€โ”€ spec.py             # Spec Annotation
โ”œโ”€โ”€ tests/
โ”‚   โ””โ”€โ”€ ...
โ”œโ”€โ”€ .gitignore
โ”œโ”€โ”€ .python-version
โ”œโ”€โ”€ README.md
โ”œโ”€โ”€ LICENSE
โ”œโ”€โ”€ pyproject.toml
โ””โ”€โ”€ uv.lock

๐Ÿงช Testing

uv run pytest -v

to run pytest-cov for coverage report:

uv run pytest --cov=defcmd --cov-report=term-missing # or --cov-report=html

The examples/ directory contains small manual scripts for trying behavior in a real terminal. examples/00_basic.py shows the basic API, while examples/01_annotated.py shows Annotated[..., Spec(...)] metadata such as help text, custom prompts, secret input, and short flags. examples/04_examples.py demonstrates usage examples with @cmd and CLI. These examples are not part of the test suite, but they are useful for manually trying interactive behavior in a real tty.

uv run python examples/00_basic.py

๐Ÿ“„ License

This project is licensed under the MIT License. See the LICENSE file for details.

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

defcmd-0.6.1.tar.gz (24.7 kB view details)

Uploaded Source

Built Distribution

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

defcmd-0.6.1-py3-none-any.whl (32.1 kB view details)

Uploaded Python 3

File details

Details for the file defcmd-0.6.1.tar.gz.

File metadata

  • Download URL: defcmd-0.6.1.tar.gz
  • Upload date:
  • Size: 24.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.27 {"installer":{"name":"uv","version":"0.11.27","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for defcmd-0.6.1.tar.gz
Algorithm Hash digest
SHA256 632f2554837da2a28f7a373c352407fcb3e907b93a3aab74c9cb93ea4816094f
MD5 8369c01a21a9d203ed24037de125aead
BLAKE2b-256 48b9c8db307eae182fc490ae60e3873f544f49b76c45e699ee4b8cb4e9b9848f

See more details on using hashes here.

File details

Details for the file defcmd-0.6.1-py3-none-any.whl.

File metadata

  • Download URL: defcmd-0.6.1-py3-none-any.whl
  • Upload date:
  • Size: 32.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.27 {"installer":{"name":"uv","version":"0.11.27","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for defcmd-0.6.1-py3-none-any.whl
Algorithm Hash digest
SHA256 20fffd7d5d3587ad852d47e5ba83eda843ed84a0766f09fd7c5ba5fef9e51b51
MD5 0a651e68e2e16182e0dbd51803a5aca7
BLAKE2b-256 a4da8999d0bb6f46dda79428164220e147d0c13fdb66cc5fd2533d55d54fa50d

See more details on using hashes here.

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