Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ and versions are tracked in the repo-root `VERSION` file.

### Added

- Add framework-specific migration guides for Click, Typer, Cement, and
`argparse`, with rollout and rollback checklists.
- Add golden success, error, inspection, log, NDJSON, and command-protocol
fixtures with Python and Node.js validators for cross-language consumers.
- Publish versioned JSON Schema artifacts for output, error, inspection, log,
Expand Down
70 changes: 70 additions & 0 deletions docs/migration-argparse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Migrating from argparse

## When this path fits

Use this path when a standard-library `argparse` CLI needs consistent runtime
state, diagnostics, cleanup, and machine-readable output. Keep the parser
stable first; replacing parsing and lifecycle in the same change makes
behavioral regressions difficult to diagnose.

## Incremental change

An `argparse` entry point commonly combines parsing and application work:

```python
import argparse


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--verbose", action="store_true")
args = parser.parse_args(argv)
print("ready (verbose)" if args.verbose else "ready")
return 0


if __name__ == "__main__":
raise SystemExit(main())
```

The lowest-risk `base-cli` adoption is to move the command tree to Click while
preserving the option and callback contract:

```python
import base_cli
import click


@click.command(name="example")
@click.option("--verbose", is_flag=True)
def cli(verbose: bool) -> None:
base_cli.get_current_context().log.info("status requested")
click.echo("ready (verbose)" if verbose else "ready")


command = base_cli.attach(cli)

if __name__ == "__main__":
raise SystemExit(base_cli.run_app(command))
```

`argparse`/Click remains responsible for parsing, help, completion, parameter
types, and usage errors. `base-cli` owns lifecycle options and hooks, context,
structured logging, redaction, runtime paths, cleanup, history, and output
contracts. If retaining `argparse` is a hard requirement, integrate the
consumer's parser at its own boundary and adopt the `base-cli` contracts
incrementally; `attach()` expects a Click command.

## Verification and rollback

- [ ] Snapshot option spelling, defaults, help text, exit codes, and parser
errors before changing the command tree.
- [ ] Test both human output and JSON/NDJSON fixtures, with diagnostics on
stderr and command output on stdout.
- [ ] Run the installed-wheel and supported-platform checks before removing
the old parser entry point.
- [ ] Keep the `argparse` entry point and previous wheel available for rollback
until every consumer has migrated.

See [`output-contracts.md`](output-contracts.md) and
[`json-contracts.md`](json-contracts.md) for the stable output boundary.
80 changes: 80 additions & 0 deletions docs/migration-cement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Migrating from Cement

## When this path fits

Use this path when Cement provides an established controller tree, hooks, and
configuration system, but the team wants the shared lifecycle and contracts of
`base-cli`. Cement can remain in place during an evaluation; migrate one
controller at a time rather than rewriting the whole application.

## Incremental change

A small Cement application commonly owns both parsing and process lifecycle:

```python
from cement import App, Controller, ex


class BaseController(Controller):
class Meta:
label = "base"

@ex(help="report status")
def status(self) -> None:
print("ready")


class Example(App):
class Meta:
label = "example"
handlers = [BaseController]


with Example() as app:
app.run()
```

The incremental target is a Click command with the same user-visible command
and options:

```python
import base_cli
import click


@click.group(name="example")
def cli() -> None:
"""Report status."""


@cli.command()
def status() -> None:
base_cli.get_current_context().log.info("status requested")
click.echo("ready")


command = base_cli.attach(cli)

if __name__ == "__main__":
raise SystemExit(base_cli.run_app(command))
```

The parser-owned boundary is the Cement/Click command tree, parameters, help,
completion, and parser errors. Cement hooks and extensions need an explicit
consumer-owned replacement or an adapter; `base-cli` does not emulate Cement's
plugin or configuration conventions. Move lifecycle concerns—logging,
redaction, runtime paths, cleanup, history, and versioned output—into the
`base-cli` boundary and keep domain services in the consumer.

## Verification and rollback

- [ ] Inventory Cement hooks, extensions, config precedence, and controller
exit behavior before moving each command.
- [ ] Run old and new commands with identical arguments and compare stdout,
stderr, exit codes, and machine output fixtures.
- [ ] Pilot a single controller and retain the Cement entry point as the
rollback until diagnostics and cleanup are equivalent.
- [ ] Remove Cement only after all required hooks have consumer-owned tests.

See [`migrations.md`](migrations.md) for the common contract and rollback
checklist.
75 changes: 75 additions & 0 deletions docs/migration-click.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Migrating from Click

## When this path fits

Use this path when the application already has a Click group, commands, and
tests that should remain intact. `base-cli` is a lifecycle layer around the
tree; it is not a replacement for Click decorators or parameter parsing.

## Incremental change

Before, the application usually owns invocation and logging directly:

```python
import click


@click.group()
def cli() -> None:
"""Example command tree."""


@cli.command()
def status() -> None:
click.echo("ready")


if __name__ == "__main__":
cli()
```

After, keep the decorators and callbacks, and attach the tree at the process
boundary:

```python
import base_cli
import click


@click.group()
def cli() -> None:
"""Example command tree."""


@cli.command()
def status() -> None:
context = base_cli.get_current_context()
context.log.info("status requested")
click.echo("ready")


command = base_cli.attach(cli, sensitive_parameters=())

if __name__ == "__main__":
raise SystemExit(base_cli.run_app(command))
```

Click still owns the group, options, parameters, help, completion, and Click
exceptions. `base-cli` adds the context, lifecycle options, structured logging,
redaction, runtime paths, cleanup, history hooks, and outcome handling. Put
workspace/configuration policy in a consumer `CliProfile`; do not hard-code
product assumptions in the framework.

## Verification and rollback

- [ ] Compare `--help`, option defaults, exit codes, and stdout with the
inventory from the old entry point.
- [ ] Add JSON/NDJSON fixtures if automation consumes command output.
- [ ] Exercise `--debug`, `--quiet`, `--keep-temp`, and configured `--json`.
- [ ] Run the installed-wheel smoke test on every supported platform.
- [ ] Keep the old console-script entry point and last known-good wheel until
the pilot passes; reverting the entry point is the rollback.

See [`output-contracts.md`](output-contracts.md),
[`json-contracts.md`](json-contracts.md), and
[`adopter-readiness.md`](adopter-readiness.md) for the operational details.
72 changes: 72 additions & 0 deletions docs/migration-typer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Migrating from Typer

## When this path fits

Use this path when typed Typer commands and dependency injection are already
valuable. Install the optional adapter so Typer remains optional for Click-only
consumers:

```bash
python -m pip install 'base-cli[typer]'
```

## Incremental change

Before, Typer owns the process entry point:

```python
import typer

app = typer.Typer()


@app.command()
def status(verbose: bool = typer.Option(False, "--verbose")) -> None:
typer.echo("ready" if not verbose else "ready (verbose)")


if __name__ == "__main__":
app()
```

After, retain the Typer declarations and attach the materialized command:

```python
import base_cli
import typer

app = typer.Typer()


@app.command()
def status(verbose: bool = typer.Option(False, "--verbose")) -> None:
context = base_cli.get_current_context()
context.log.info("status requested")
typer.echo("ready" if not verbose else "ready (verbose)")


command = base_cli.attach_typer(app, name="example")

if __name__ == "__main__":
raise SystemExit(base_cli.run_app(command))
```

Typer remains responsible for decorators, type-driven parameters, nested apps,
help, completion, dependency injection, and Typer/Click exceptions. The
adapter returns Typer's generated Click command; `base-cli` owns the lifecycle,
context, logging, redaction, runtime state, cleanup, history hooks, and output
contracts. For custom configuration use `base_cli.attach_typer(app,
app=base_cli.App(...))`.

## Verification and rollback

- [ ] Test the supported Typer and Python matrix, including the vendored Click
boundary used by newer Typer releases.
- [ ] Compare typed defaults, callback injection, help, completion, and exit
codes with the original app.
- [ ] Add contract fixtures and exercise debug, quiet, JSON, and retained
diagnostics.
- [ ] Keep the old Typer entry point available until the wheel-first pilot is
green; restore it to roll back without rewriting command code.

See [`typer-adapter.md`](typer-adapter.md) for adapter details.
46 changes: 43 additions & 3 deletions docs/migrations.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,48 @@
# Migration guide

This page collects the format for changes that affect the public `base-cli`
contract. The compatibility rules and deprecation timeline are defined in
[`api-stability.md`](api-stability.md).
This page is the starting point for adopting `base-cli` in an existing Python
CLI. It collects framework-specific recipes as well as the format for changes
that affect the public `base-cli` contract. The compatibility rules and
deprecation timeline are defined in [`api-stability.md`](api-stability.md).

## Choose a migration path

- [Click](migration-click.md) — keep an existing Click command tree and add a
shared lifecycle boundary.
- [Typer](migration-typer.md) — keep typed Typer declarations and attach the
generated command to the lifecycle boundary.
- [Cement](migration-cement.md) — keep Cement while evaluating the boundary,
then move command definitions incrementally to Click plus `base-cli`.
- [argparse](migration-argparse.md) — keep parser behavior stable while
replacing application-owned lifecycle code one command at a time.

The [framework choice guide](framework-choice.md) explains the boundary and
the [adopter readiness guide](adopter-readiness.md) is the production handoff
checklist. These recipes assume a pinned `base-cli` release and a wheel-first
smoke test before changing a user's command behavior.

## Common rollout and rollback checklist

1. Inventory command names, options, exit codes, configuration precedence,
output consumed by automation, and log/diagnostic locations.
2. Add `base-cli` to the lock file and preserve the existing parser and
callback tests. Start with one low-risk command.
3. Attach the existing tree (or add a small `App`) and move policy into a
consumer-owned `CliProfile`. Mark secrets as sensitive.
4. Add success, usage-error, and unexpected-error contract fixtures. Keep
stdout reserved for command output and diagnostics on stderr.
5. Run the full platform/dependency matrix, then compare old and new output
and retained run metadata in a pilot environment.
6. Keep the previous wheel, configuration schema, and output contract
available for the documented compatibility window. If the pilot fails,
restore the previous entry point and wheel, and retain the evidence for a
follow-up issue.

`base-cli` owns invocation lifecycle, context, logging, redaction, runtime
state, cleanup, history hooks, and versioned output contracts. The parser
(Click, Typer, Cement, or `argparse`) remains responsible for command trees,
parameters, help, completion, and parser-specific errors. Product callbacks,
services, configuration policy, and domain schemas remain consumer-owned.

## Migrating a deprecated API

Expand Down
4 changes: 4 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ nav:
- API stability: api-stability.md
- Dependency support: dependency-support.md
- Migration guide: migrations.md
- Click migration: migration-click.md
- Typer migration: migration-typer.md
- Cement migration: migration-cement.md
- argparse migration: migration-argparse.md
- Output contracts: output-contracts.md
- JSON contracts: json-contracts.md
- JSON Schema artifacts: schemas.md
Expand Down
4 changes: 4 additions & 0 deletions scripts/validate_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
"integrations.md",
"json-contracts.md",
"local-config.md",
"migration-argparse.md",
"migration-cement.md",
"migration-click.md",
"migration-typer.md",
"migrations.md",
"output-contracts.md",
"performance.md",
Expand Down
Loading