Skip to main content

pyclig

A Python Click wrapper with Command Line Interface Guidelines (CLIG) paradigms.

click owns parsing, help, shell completion & the test runner. pyclig adds the conventions on top: STDOUT for output & STDERR for messages, exit codes, error rendering, logging, output primitives, run flags & standard flags. Options, arguments & types are plain click.

CLIG is treated as a contract: a CLI conforms with no application code wherever pyclig can do it alone, gets one obvious interface for each rule that needs application input, and pyclig.testing.assert_conforms reports the rest by rule ID. docs/clig-coverage.md has the rule-by-rule status and docs/roadmap.md what each release adds. Pin pyclig~=0.3.0 while it is pre-1.0: a minor release may break, and ~=0.3 would admit 0.4.

Usage

import click
import pyclig

from dkais import exceptions


@pyclig.group(
    "dkais",
    short_help="DMA AIS data manager",
    examples=["dkais list --start 2026-10"],
    errors=(exceptions.Error,),
    epilog="For more information see https://github.com/andyrids/dkais.",
)
def cli() -> None:
    """Download DMA AIS archives and convert them to Parquet."""


@cli.command("list", examples=["dkais list --start 2026-10 --json"])
@pyclig.options.json_output()
@pyclig.options.plain()
@click.option("--start", metavar="DATE", help="The range start.")
@pyclig.pass_context
def list_(ctx: pyclig.Context, start: str | None, json_output: bool) -> int:
    """List bucket archives & local state."""
    if ctx.format == "human":
        ctx.out.print(f"from {start}")
    else:
        click.echo(f"start\t{start}")
    return pyclig.ExitCode.OK


if __name__ == "__main__":
    cli()

Point the entry point at the group: [project.scripts] dkais = "dkais.__main__:cli". Always name the group - the name is the program name, also under python -m, and sets the shell completion variable (_DKAIS_COMPLETE). A single-command tool uses pyclig.command() instead, and gets everything a command of a group does.

Commands take examples= too: their --help leads with them under EXAMPLES:.

What every command gets

Feature Behaviour
-h, --help Usage, description, examples, commands, options, global options & epilog
-h anywhere Help, exit 0, even after a typo - anywhere before --
Bare prog Concise help on STDOUT, exit 0 - --help without options & epilog
prog cmd needing arguments Concise help on STDERR, exit 2 (no_args_is_help=True)
-v, --version prog version from the metadata, else 0.0.0+unknown; an app's -v wins
Run flags -d, -q, --no-color & --no-input, before or after the command name
help, help cmd... A group's or the named command's help, exit 0; unlisted, an app's wins
Subcommands @cli.command() & @cli.group() create pyclig.Command & pyclig.Group
Arguments Reach commands as typed - globs & variables are not expanded on Windows either
Streams UTF-8 STDOUT & STDERR, so ✓ or → never fail on a cp1252 pipe

ctx.obj is left to the application.

Run flags

Run flags are about how a run behaves, never what a command does, so they are on every command and act as soon as they are parsed - -d applies even when a later option is invalid:

Flag Effect Read as
-d, --debug DEBUG logging, with times & call sites -
-q, --quiet Drops info lines & animation; notices, hints & STDOUT stay ctx.quiet
--no-color No colour on either console; bold & dim stay -
--no-input confirm & prompt= options fail instead of asking ctx.can_prompt

A command's own option with the same spelling wins: copy -d /tmp reaches the app, and --debug still works there. The root's --help lists the run flags under GLOBAL OPTIONS:; a subcommand's names them on one line.

An application declares its own with cls=pyclig.GlobalOption on the command whose subtree accepts it, usually the root group. The callback is a plain click callback. It runs only when the flag was given - on the command line or through envvar= - and may run once per level of a nested command line that sees it (each level reads the environment), so it must be idempotent:

def _trace(ctx: click.Context, param: click.Parameter, value: bool) -> None:
    ctx.meta["trace"] = True


@pyclig.group("dkais")
@click.option(
    "--trace", cls=pyclig.GlobalOption, callback=_trace, help="Trace the run."
)
def cli() -> None: ...

Errors & exit codes

Commands return an exit code (or None for 0). Anything else is mapped by the runner, with messages on STDERR:

Outcome STDERR Exit
return code - code
click.UsageError, ctx.fail() error:, the failing command's usage, a --help hint 2
Other click.ClickException error: message its code
Ctrl-C, in a command or at a prompt warning: interrupted 130
Declined click.confirm(abort=True) error: aborted 1
A type in errors= error: message, the traceback first with -d 1
... with a hint attribute ... then the hint, dim, last 1
... with an exit_code attribute ... that code, if a non-zero int
Broken STDOUT pipe (prog | head -1) - 1
Any other exception trimmed traceback, error:, bug report hint 70

pyclig.ExitCode names the codes: OK, FAILURE, USAGE, INTERNAL & INTERRUPT. The bug report hint names the distribution's Issues project URL. Messages are never parsed as Rich markup: user input such as [/b] or :warning: prints as typed.

hint & exit_code are read by attribute, so the application's exceptions need no pyclig import - a class attribute on the exception type is enough:

class ToolNotFoundError(Error):
    hint = "Run the hook in the environment pkgdx is installed in, e.g. via `uv run`"

A command may catch Ctrl-C or its own errors to print a tailored message first - a returned code passes through untouched. Wrap third-party failures (e.g. network errors) in an errors= type, so users see a message instead of a crash. errors= goes on the root; add_command rejects it on a subcommand, where the runner would ignore it.

Output

ctx.out (STDOUT) & ctx.err (STDERR) are Rich consoles created for each run, with markup, emoji codes & highlighting off. Each console tells the truth about its own stream: is_terminal is the real TTY check, so FORCE_COLOR means colour only - a piped console keeps its colour but stays one line per message, never wraps and never animates. NO_COLOR, TERM=dumb & --no-color drop colour; TTY_COMPATIBLE=1 makes a pipe a terminal.

ctx.format says what a command producing records should print: human on a terminal, otherwise plain; the per-command flags --plain (pyclig.options.plain()) & --json (pyclig.options.json_output()) override it, --json winning in any order. Only commands that produce records take the flags, so help never advertises a flag a command ignores. ctx.emit(records, table=..., caption=...) prints the records in that format:

Format STDOUT Caption
json An indented list; values JSON cannot encode print as str() Dropped
plain Tab-separated rows, no header, columns from the first record's keys info(ctx.err)
human table(), or a default table with a column per key Below the table

A plain cell never breaks its row: None is empty, bools are true/false, tabs & newlines become one space. Without records JSON prints [] and the others only the caption. table is a factory, so cells can depend on other fields and rich.table stays off the fast paths.

with ctx.pager(): pages what the block prints on ctx.out, in human format only - plain & json are for pipes. It goes through click.echo_via_pager, so PAGER is honoured and less gets LESS=-FIRX when LESS is unset. On Windows paging is opt-in: with PAGER blank the output prints directly, because click's fallback there is more, which garbles UTF-8 and cannot scroll; PAGER=less (Git for Windows ships one) pages in colour. Output is held until the block ends, and click.echo inside it bypasses the page.

Messages & feedback take the console first, then keyword-only arguments:

Primitive On a terminal Otherwise
notice(level=, message=, items=()) Bold level:, wrapped; items indented One line each
info(message=) Dim; dropped with -q Plain; dropped with -q
hint(message=) Dim; kept with -q Plain; kept with -q
hanging(head="✓", message=) Wrapped under the message Same
row(mark=, label=, detail=, width=) mark label detail, never wrapped Same
summary(message=, failed=) ✓ message, or ! message when failed; wrapped One line
status(message=) A spinner around the block No-op
progress(columns=, **kwargs) A transient Rich Progress Disabled

-q drops info, row (unless essential=True), summary (unless failed=True) & animation; notice & hint stay. status & progress animate only on an interactive console. A grouped failure is one notice(items=...) then a hint with the remedy. Control characters, including terminal escapes, are dropped from messages, and a str is never parsed as markup; a rich.text.Text with a style prints styled, as given. That holds for the description column & str columns of progress too, so prefer them to a TextColumn of your own, which renders as you built it.

Standard flags

pyclig.options holds the flags with standard names, each taking a help= override:

Factory Flag Parameter Scope
json_output() --json json_output; sets ctx.format Commands producing records
plain() --plain ctx.format == "plain" Commands producing records
select_all() -a, --all select_all Per command
force() -f, --force force Per command
dry_run() -n, --dry-run dry_run Per command

Prompts & confirmation

A prompt needs a terminal on STDIN to answer it and on STDERR to see it, and --no-input refuses every prompt; ctx.can_prompt is that test. pyclig.confirm() asks before a large or destructive action, and --force skips the question:

@cli.command()
@pyclig.options.force()
@pyclig.pass_context
def purge(ctx: pyclig.Context, force: bool) -> None:
    """Delete every local archive."""
    pyclig.confirm(ctx, message="Delete every local archive?", force=force)
Case STDERR Exit
force=True - continues
STDIN or STDERR is not a terminal error: naming --force, the usage, a --help hint 2
--no-input error: naming --no-input & --force, the usage, a hint 2
y or yes The [y/N] prompt continues
n, an empty answer or EOF error: aborted 1
Ctrl-C warning: interrupted 130

A script cannot answer the prompt, so the run fails rather than read an answer from a pipe, and with 2>log it fails rather than hide the question in the log. Call confirm before starting a Progress or Status. A click option with prompt= follows the same rule: it prompts on STDERR, and when the run cannot prompt it takes its default, or fails naming the option when it has none - so --no-input never lies.

Logging

Log through the package logger - logging.getLogger(__name__) in any module of the package that defines the group. Libraries add only a NullHandler, in the package __init__.py:

logging.getLogger(__name__).addHandler(logging.NullHandler())

During a run, warnings & errors print as warning: / error: notices on ctx.err. With -d, DEBUG records, times & call sites are added - laid out by a RichHandler when STDERR is a terminal. Otherwise each record is one line that never wraps, with any traceback after it in plain text:

[2026-10-31T09:14:03+0000] DEBUG    [convert] converting 'nope'  types.py:47

The logger's handlers, level & propagation are restored when the run ends.

Testing

from pyclig.testing import assert_conforms, invoke


def test_list_rows_when_piped() -> None:
    """Assert `list` prints tab-separated rows when STDOUT is a pipe."""
    result = invoke(cli, ["list"], out_tty=False, err_tty=True)
    assert result.exit_code == 0
    assert "\t" in result.stdout


def test_cli_conforms() -> None:
    """Assert the CLI follows every guideline pyclig can check."""
    assert_conforms(cli, ignore={"ARG-16": "`--token` also reads a file"})

invoke runs any pyclig.Command or Group and returns click's Result, with stdout & stderr apart. out_tty, err_tty, color & width set up each console; env sets (or, with None, unsets) variables. Colour & terminal variables from the developer's shell are cleared. Never patch Rich private attributes. in_tty=True & err_tty=True let pyclig.confirm & prompt= options prompt and read their answer from input; click echoes that answer to STDERR after the prompt (... [y/N]: y).

assert_conforms walks the CLI and reports deviations by the rule IDs of docs/clig-coverage.md: flags without a long form or help text, standard short flags with another meaning, secret-looking flags & environment variables, a root without examples, plain click children, and help or unknown-flag probes that misuse exit codes or streams. It never runs a command body. check returns the Findings instead of asserting; ignore needs a reason per rule.

Parser-less entry points

pyclig.run() gives a pre-commit hook shim, which leaves sys.argv to the tool it wraps, the same exit codes, error rendering & logging. It enters a pyclig.Context, so the function may be a pyclig.pass_context function using ctx.err:

def ruff_format() -> NoReturn:
    pyclig.run(
        lambda: core_ruff_format(sys.argv[1:]),
        errors=(exceptions.Error,),
    )

Migrating from argparse

  • Each subcommand becomes a @cli.command(); shared options become one decorator stacking click.options. Commands are listed in the order they are added.
  • click shows a command's docstring as its help: put \f after the help paragraph, so the Args: & Returns: sections stay out of --help. Pass help= for a description that is not an imperative docstring line.
  • Custom argument types become click.ParamType[T] subclasses calling self.fail().
  • parser.error() becomes raise click.UsageError(...) (or ctx.fail()).
  • A type= function checking a minimum becomes click.IntRange(min=1).
  • An environment variable default becomes envvar=, with show_envvar=True so a missing value names it.
  • Module-level consoles become ctx.out / ctx.err, passed to helpers as arguments.
  • argparse accepted unambiguous option prefixes (--st for --start); click does not.

Metadata

Release files for pyclig 0.4.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pyclig 0.4.0
File Size Uploaded Provenance Actions
pyclig-0.4.0.tar.gz 41.6 kB Without provenance Details for pyclig-0.4.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for pyclig 0.4.0
File Interpreter ABI Platform Actions
pyclig-0.4.0-py3-none-any.whl Python 3 none any Details for pyclig-0.4.0-py3-none-any.whl

Total release size: 87.4 kB

Release files / pyclig-0.4.0.tar.gz

Download URL pyclig-0.4.0.tar.gz
Size 41.6 kB
Tags Source
SHA-256 checksum
How to use checksums
bada5cac93171cbc2d1066b75638c7b556b14782836aa74f17c4275bd738e534
BLAKE2b-256 checksum
How to use checksums
c077a86dd2a1c946e9738ec36b9c0945bcb58ed1558198d3000e64a4d73eb27f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.7 {"installer":{"name":"uv","version":"0.13.7","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}

Release files / pyclig-0.4.0-py3-none-any.whl

Download URL pyclig-0.4.0-py3-none-any.whl
Size 45.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
975dfd4ba2dd6b824460fb35dd1140262fa6651a6c69a86438ceb80f830458cc
BLAKE2b-256 checksum
How to use checksums
ef74fd580d503c4700fc910309b6821e1c32cb7c7fc63a9c5c785c0d7c9c37cc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.7 {"installer":{"name":"uv","version":"0.13.7","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}

Security for pyclig 0.4.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page