Skip to content

Latest commit

 

History

History
198 lines (174 loc) · 12.3 KB

cli-style-guide.md

File metadata and controls

198 lines (174 loc) · 12.3 KB

CLI and Python API style guide

How bundleup's command line and library API should look and behave. Synthesized from round 5 research (compass, primary; gemini, agrees on almost everything). Adopted via ADR-0016 (Proposed). Where this guide and ADR-0011 differ, the user decides during the ADR-0011 review.

Command shape (decided 2026-10-05)

Verbs (option A). bundleup build [PATH] builds, where PATH is a project directory or a script; later bundleup check, bundleup cache …. Bare bundleup prints help. Chosen by the owner (ADR-0016) because new commands can never collide with a project path, and it matches uv and cargo.

Option Example For Against
A. Verbs (Compass) bundleup build, bundleup check, bundleup targets; bare bundleup prints help Matches uv and cargo, which users run alongside bundleup. Adding commands later (check, targets, cache clean) never collides with a project path. clig.dev warns against default commands Two extra words for the main action
B. Default action (Gemini, current code) bundleup [path] builds; bundleup check, bundleup targets are extras The esbuild feel: bundleup alone does the job bundleup check is ambiguous if a folder is named check; every new subcommand is a potential breaking change

Rules

Flags and defaults

  1. Zero config builds correctly for this machine. Python version from requires-python / .python-version as uv resolves it; output dist/<name>.pyz.
  2. Reuse uv's flag names and meanings where we overlap: --python, --locked, --frozen, --project, --python-platform (uv's platform vocabulary, e.g. x86_64-manylinux_2_28). No synonyms.
  3. Every flag has a long form. Short flags only for -o/--output, -q/--quiet, -v/--verbose, -n/--dry-run, -h/--help, -V/--version.
  4. No abbreviations: ArgumentParser(allow_abbrev=False).
  5. Booleans come in pairs (--compile/--no-compile, argparse BooleanOptionalAction).
  6. List options repeat or take commas (--python-platform a --python-platform b or --python-platform a,b; ADR-0051).
  7. No named targets (ADR-0039): a destination's needs are ordinary flags (--python-platform, --format, --max-size), and destinations are documented as recipes. (Until 2026-10-07: presets that printed their expansion.)
  8. --dry-run / -n resolves everything, prints the plan and where each setting came from, and writes nothing.
  9. In CI (CI set), behave as --locked: a stale lockfile is an error, not a silent bundle missing a new dependency. Only when a lockfile exists: a PEP 723 script often has none, and uv export --locked refuses to start without one (found 2026-10-05).

Output

  1. stdout is for machine output only (--json, listings). All human messages, warnings and errors go to stderr.
  2. On success, print at most two short lines, e.g. Bundled myapp 1.4.0 → dist/myapp.pyz (8.2 MiB) in 1.37s.
  3. Progress only on a TTY and only for steps over ~200 ms: one rewriting line, never in CI.
  4. Colour off when the stream isn't a TTY (checked per stream), NO_COLOR is set, TERM=dumb, or --color never. FORCE_COLOR / --color always turn it on. Colour only for error (red), warning (yellow), hint (cyan), and bold paths.
  5. ASCII fallback (->, no spinner) when stderr isn't UTF-8.
  6. Quiet and verbose levels: -q = warnings and errors only, -qq = errors only; -v = step timings, -vv = the uv commands run.

Machine output and agents

  1. --json writes exactly one JSON document to stdout at exit, on success and failure, with the normal exit code. Human stderr output is suppressed.
  2. It carries schema_version. Within a version, changes are additive only; consumers ignore unknown keys. Breaking changes need a new version. Publish a JSON Schema.
  3. Every diagnostic has a stable code slug (e.g. lock-outdated), level, message, and optional hint, file/line, package. Scripts and agents branch on codes, not prose.
  4. Never prompt.
  5. Deterministic output: sort lists; paths relative to the project; durations and absolute paths isolated in known fields.

Errors

  1. Format: error: <what happened>, optional context lines, then hint: <what to do next> last (uv's style).
  2. Point at what to edit: file:line with a caret for config errors; package==version for dependency errors.
  3. Expected failures never show a traceback. Every expected failure is a BundleupError subclass the CLI renders. Unexpected exceptions are bugs: exit 3, "this is a bug" line, issue link, full traceback with -v.
  4. Suggest fixes for typos in subcommands, choices and config keys, but never auto-run them (argparse suggest_on_error on 3.14+, difflib before).
  5. When uv fails, quote its stderr verbatim, indented under bundleup's one-line explanation.

Exit codes

Code bundleup.ExitCode Meaning
0 OK Success (warnings allowed unless --strict)
1 BUILD_FAILED Expected failure: stale or missing lock, no compatible wheel, uv failure, check found problems, --strict warnings
2 USAGE_ERROR Invalid flags, unknown command or format, invalid [tool.bundleup]
3 INTERNAL_ERROR A bug in bundleup
130 INTERRUPTED Ctrl-C
  1. Don't add codes per failure type; use the JSON code for detail.
  2. Warnings never change the exit code unless --strict.

Configuration

Rank Source Example
1 Flags --format lambda --python 3.13
2 Environment BUNDLEUP_PYTHON=3.12, UV_LOCKED, NO_COLOR, CI
3 [tool.bundleup] in pyproject.toml format = "lambda"
4 Defaults host platform, dist/<name>.pyz
  1. Env vars are BUNDLEUP_ + the long flag in SCREAMING_SNAKE. Show them in --help.
  2. Per-run settings (--json, -q, --dry-run, --color) are flags/env only, rejected in [tool.bundleup].
  3. Unknown keys in [tool.bundleup] are errors with a suggestion.
  4. No user-level config files until users ask.

Help, speed, stability

  1. --help: one-line purpose, 3–4 examples, common options first, docs and bug-report URLs. Under ~30 lines.
  2. Per-flag help shows default, allowed values and [env: …].
  3. Generate the CLI reference doc from the parser in CI, and fail if it drifts.
  4. Startup budget: --version and --help must not import heavy modules or start uv. Measure on CI with python -X importtime.
  5. Contracts: commands, flags, env vars, [tool.bundleup] keys, exit codes, JSON schema and diagnostic codes. Human-readable text is not a contract.
  6. Versioning: uv's scheme until 1.0 (minor = breaking). Deprecate in three steps: warn, then error with the replacement, then remove. Never change what an existing flag means.

Feel: delight, with restraint

From "Satisfying Terminal UX" research done for another project (filegoblin), keeping only what fits a build tool. Everything here applies to TTY output only; rules 10–15 still hold.

  1. Show the work, not a bare spinner. For steps over ~1 s, the progress line names the step and the current item or count (Fetching wheels 12/23 · numpy-2.1.3), so it's obviously alive. A spinner with no detail is reserved for short, unknown-length waits.
  2. Steps read like cargo and uv: a short bold verb at the left margin (Resolved, Fetched, Bundled), then the detail. Easy to scan.
  3. End with one clear completion line (rule 11), visually distinct from the progress that preceded it, so the task feels finished.
  4. Colour has meaning; metadata is dimmed. Sizes, timings and paths in dim/grey, the important word bold, red/yellow/cyan only for error/warning/hint. No decorative colour.
  5. No emoji. At most ✓/→ on UTF-8 terminals, with ASCII fallbacks.
  6. The output path is a clickable link (OSC 8 hyperlink) when stderr is a TTY that supports it; plain text otherwise.
  7. Rejected from that research: artificial stutters or fake progress, animated "illusion" bars, sounds and haptics. They manipulate rather than inform, and they don't belong in CI-adjacent tools.

Python library API

  1. The public API is exactly bundleup.__all__. Everything else lives in _underscore modules. Ship py.typed.
  2. Entry points: build(), check(), verify(), with keyword-only typed options (a frozen BuildOptions dataclass mirroring the CLI).
  3. Results are frozen dataclasses whose to_json_dict() is the --json schema.
  4. One exception root, BundleupError, with .code, .hint, .exit_code; subclasses such as ConfigError, LockfileOutdatedError, NoCompatibleWheelError, UvError.
  5. The library never prints, exits, configures logging, or touches sys.stdout/os.environ. Progress goes through an optional callback; warnings come back in the result.
  6. The CLI is a thin layer: argv → BuildOptions → build() → render → ExitCode, in one main(argv: list[str] | None = None) -> int.
  7. Guard the API with a test that snapshots __all__ and every public signature.

Implementation

  1. argparse, not Click/Typer/Cyclopts: no extra dependencies, fastest startup. Add an Examples: epilog and [env: …] suffixes with a custom HelpFormatter.
  2. No Rich. A ~80-line _term.py handles TTY detection, the colour rules, four styles, ASCII fallback and a TTY-only spinner.

Testing the CLI

  1. Snapshot every output mode (success, warnings, each error class, crash) for stdout and stderr, with durations and paths redacted.
  2. Exit-code contract tests through a real subprocess (python -m bundleup), including Ctrl-C → 130 and an injected crash → 3.
  3. Output-mode matrix: NO_COLOR, FORCE_COLOR, TERM=dumb, piped (non-TTY) and a real TTY.
  4. JSON contract test: validate against the published schema; one document on stdout even with warnings.

Implementation status (2026-10-05)

Commands: build, check (ADR-0024), targets, verify (ADR-0019) and cache list|clean (ADR-0022). Implemented in src/bundleup/_cli.py, _errors.py, _term.py and the public API (ADR-0018), with contract tests in tests/test_cli.py and the generated CLI reference:

  • verbs (bundleup build), uv's flag names, long forms, allow_abbrev=False (rules 1-4);
  • stdout/stderr split, two-line success, TTY-only status line, colour rules, ASCII fallback, -q/-qq/-v/-vv (10-15), clickable output path (56);
  • --json with schema_version, stable code slugs, JSON Schema (16-20); error:/hint: messages, uv output quoted under the message, exit codes 0/1/2/3/130, "this is a bug" for crashes, a suggestion for mistyped commands (21-27);
  • BUNDLEUP_OUTPUT, BUNDLEUP_PYTHON, BUNDLEUP_ENTRY (28); --locked is the default when a lockfile exists (9);
  • help under ~30 lines with examples and [env: ...] (32-33), generated reference checked by a test (34), startup budget checked by a test (35);
  • no named targets; --max-size (7; ADR-0039);
  • [tool.bundleup] in pyproject.toml or a PEP 723 script, below flags and environment (29-31, ADR-0032);
  • --strict (27) on build and check; diagnostics carry package, file and line (18);
  • library API: __all__, py.typed, build(BuildOptions, progress=...), frozen results with to_json_dict(), one exception root, a library that never prints or exits, API snapshot test (38-44); snapshots of every output mode and exit-code tests (47-50).

Not yet: --dry-run (8); progress counts per item (51); Ctrl-C and crash exit codes are tested in-process rather than through a subprocess (48).