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 @@ -9,6 +9,8 @@ and versions are tracked in the repo-root `VERSION` file.

### Added

- Add the public API stability and deprecation policy, migration guide, and
`base_cli.deprecated()` warning helper with contract-test guardrails.
- Add immutable `LifecycleOptions` and `LifecycleOption` policies for enabling,
disabling, renaming, and configuring each standard option independently, with
normalized `LifecycleValues` stored in namespaced Click metadata without
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ Automation-facing JSON output, errors, and logs are opt-in through the
versioned contracts documented in [`docs/json-contracts.md`](docs/json-contracts.md).
Human output and Click error behavior remain the default.

The supported public facade, compatibility promises, deprecation warning
mechanism, and migration requirements are documented in
[`docs/api-stability.md`](docs/api-stability.md) and
[`docs/migrations.md`](docs/migrations.md).

Shared record renderers keep machine output stable: CSV and TSV stream
one-pass iterables without headers or footers, while terminal tables account
for Unicode display width and safely truncate oversized cells. See
Expand Down
122 changes: 122 additions & 0 deletions docs/api-stability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# API stability and deprecation policy

This document is the compatibility contract for `base-cli`. It applies to the
current pre-1.0 releases and will be updated before the 1.0 release. The
repository's contract tests and release checklist are expected to change with
this document, so a policy change is itself a reviewed, changelogged change.

## Public surface

The supported Python facade is `import base_cli`. The names in
`base_cli.__all__` are the public facade; documented names in the explicitly
exported modules (`base_cli.command_protocol`, `base_cli.json_contracts`, and
the other modules listed in their module-level `__all__`) are public as well.
Names beginning with `_`, modules not documented here, and implementation
details are private and may change without notice.

The following machine-facing contracts are public and versioned:

- command framing defaults to `COMMAND_PROTOCOL_V1`; record schemas are owned
and registered by the consumer;
- JSON output, error, and log records use `schema_version: 1` and the schema
identifiers documented in [`json-contracts.md`](json-contracts.md); and
- the exported callable and type signatures exercised by the public API tests.

Human-readable messages, table layout, log wording, temporary directory names,
and history implementation details are not stable machine interfaces unless a
separate contract document says otherwise. Consumers that need automation
should select the JSON or record protocol contracts.

`base_cli.experimental` is reserved for preview APIs. No experimental symbols
are currently shipped. A future preview must live under that namespace, be
labelled experimental in its documentation, and must not be re-exported from
the stable facade until it is promoted.

## Versioning and compatibility

Starting with 1.0, `base-cli` follows Semantic Versioning:

- a **MAJOR** release may remove or change public APIs and versioned contracts;
- a **MINOR** release adds backwards-compatible functionality and may begin a
deprecation; and
- a **PATCH** release contains compatible fixes, security fixes, and docs.

Until 1.0, the leading zero is meaningful: patch releases remain compatible,
while a minor release is a compatibility boundary and may contain a breaking
change. We still prefer additive changes, and any pre-1.0 break must include a
warning where practical, a migration path, and a changelog entry. Consumers
that need a frozen API should pin a minor release (for example, `~=0.3.0`).

The core package requires Python `>=3.10` and currently tests CPython 3.10
through 3.14 on Linux, macOS, and Windows. Core runtime dependencies are
Click `>=8.1` and PyYAML `>=6.0`. Optional integrations are independently
versioned and constrained in `pyproject.toml`: Typer `>=0.12,<0.26`, Rich
`>=13.7,<15`, and OpenTelemetry API `>=1.24,<2`. The lower bounds are the
minimum supported versions; a dependency major release is supported after it
passes the compatibility suite. A future minor release may drop an end-of-life
Python or dependency window with a migration note.

Platform tier details and the operating-system support test matrix are kept in
[`platform-support.md`](platform-support.md).

## Deprecation process

Use the public `base_cli.deprecated` decorator for callable APIs:

```python
from base_cli import deprecated


@deprecated("0.3", remove="1.0", alternative="new_name")
def old_name(value: str) -> str:
return new_name(value)
```

The decorator preserves the callable's metadata and behavior, and emits a
`BaseCliDeprecationWarning` with `stacklevel=2` on every call. Applications can
show or fail on these warnings with the standard `warnings` filters. The
warning identifies the release that introduced the deprecation, the planned
removal release, and (when available) the replacement API.

Every deprecation must:

1. remain supported for at least **two minor releases and 90 calendar days,
whichever is longer**;
2. include a migration note in [`migrations.md`](migrations.md) or the relevant
contract document;
3. appear in `CHANGELOG.md` under the release that introduces the warning and
the release that removes the API; and
4. be removed only in the stated removal release (or a later release), except
for an urgent security or legal fix that is explicitly documented.

The removal PR must delete the contract test for the old symbol only after the
replacement and migration guidance are present. A deprecation is not complete
until the warning, docs, tests, and changelog agree.

## Contract guardrails

`tests/test_public_api.py` verifies that every facade export is resolvable,
that module `__all__` surfaces do not silently drift, and that private legacy
names stay absent. `tests/test_api_stability.py` additionally verifies the
warning behavior, the default command-protocol header, and the JSON v1 schema
identifiers and envelope fields. Changes to an exported symbol or a versioned
schema therefore require an intentional test and documentation update before
release.

When a schema must evolve incompatibly, add a new version and an adapter rather
than changing the meaning of an existing field in place. Keep the old version
available for the same deprecation window and document the migration.

## Release checklist

Before publishing a release, maintainers should confirm:

- the supported Python and dependency windows still match CI and `pyproject.toml`;
- public exports and schema constants have contract-test coverage;
- each deprecation has a warning, removal target, migration note, and changelog
entry; and
- the release notes call out any pre-1.0 compatibility boundary or contract
version addition.

See [`releasing.md`](releasing.md) for the mechanical package and publishing
steps.
53 changes: 53 additions & 0 deletions docs/migrations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# 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).

## Migrating a deprecated API

1. Upgrade to the first release that emits the warning.
2. Replace the old symbol with the alternative named in the warning and in the
release notes.
3. Run tests with `BaseCliDeprecationWarning` enabled so no old call sites are
missed:

```python
import warnings

from base_cli import BaseCliDeprecationWarning

warnings.simplefilter("error", BaseCliDeprecationWarning)
```

4. Remove temporary compatibility shims before the stated removal release.

Warnings are ordinary Python warnings, so applications can instead record or
display them with their normal `warnings` configuration.

## Schema migrations

Versioned JSON envelopes and command framing must not change meaning in place.
Add a new schema or protocol version, keep the old version during its support
window, and provide an adapter when practical. A schema migration note should
include:

- old and new version identifiers;
- field additions, removals, and type changes;
- producer and consumer rollout order; and
- the release where the old version will stop being accepted.

## Migration note template

```markdown
### `old_name` → `new_name`

- **First warning:** 0.x.y
- **Removal target:** 1.0.0
- **Why:** explain the problem in one sentence.
- **Action:** show the smallest before/after example.
- **Compatibility:** describe the temporary adapter or schema version.
```

For release mechanics, see [`releasing.md`](releasing.md). For the public
surface and deprecation requirements, see [`api-stability.md`](api-stability.md).
6 changes: 5 additions & 1 deletion lib/python/base_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,15 @@ def _resolve_version() -> str:

__version__ = _resolve_version()

from . import command_filters, command_protocol, extensions, history, integrations, json_contracts, testing
from . import command_filters, command_protocol, deprecations, extensions, history, integrations, json_contracts, testing
from .attachment import (
AttachmentAdapter,
AttachmentContextFactory,
AttachmentContract,
AttachmentServiceFactory,
)
from .config import BatteriesIncludedConfigLoader, ConfigSnapshot, FrameworkConfig
from .deprecations import BaseCliDeprecationWarning, deprecated
from .app import (
App,
argument,
Expand Down Expand Up @@ -143,6 +144,7 @@ def _resolve_version() -> str:
"AttachmentServiceFactory",
"TyperAdapter",
"BatteriesIncludedConfigLoader",
"BaseCliDeprecationWarning",
"BOOLEAN",
"ApplicationStateT",
"CliProfile",
Expand Down Expand Up @@ -179,6 +181,7 @@ def _resolve_version() -> str:
"command_filters",
"command_matches",
"command_protocol",
"deprecations",
"json_contracts",
"JSON_CONTRACT_VERSION",
"JSON_ERROR_SCHEMA",
Expand All @@ -202,6 +205,7 @@ def _resolve_version() -> str:
"command",
"configure_logger",
"delegated_display_command",
"deprecated",
"get_command_app",
"get_current_context",
"get_typer_command",
Expand Down
53 changes: 53 additions & 0 deletions lib/python/base_cli/deprecations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Small, consistent deprecation primitives for public base-cli APIs."""

from __future__ import annotations

import warnings
from collections.abc import Callable
from functools import wraps
from typing import ParamSpec, TypeVar


P = ParamSpec("P")
R = TypeVar("R")


class BaseCliDeprecationWarning(DeprecationWarning):
"""Warning emitted when a supported base-cli API is being retired."""


def deprecated(
since: str,
*,
remove: str,
alternative: str | None = None,
) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""Mark a callable as deprecated while preserving its normal behavior.

``since`` and ``remove`` are release identifiers, not free-form dates. The
decorator emits :class:`BaseCliDeprecationWarning` on every call so an
application can choose whether to show, record, or fail on the warning.
``stacklevel=2`` points the warning at the caller rather than this helper.
"""

if not since.strip():
raise ValueError("since must not be empty")
if not remove.strip():
raise ValueError("remove must not be empty")

def decorate(function: Callable[P, R]) -> Callable[P, R]:
message = f"{function.__qualname__} is deprecated since {since} and will be removed in {remove}."
if alternative:
message += f" Use {alternative} instead."

@wraps(function)
def wrapped(*args: P.args, **kwargs: P.kwargs) -> R:
warnings.warn(message, BaseCliDeprecationWarning, stacklevel=2)
return function(*args, **kwargs)

return wrapped

return decorate


__all__ = ["BaseCliDeprecationWarning", "deprecated"]
Loading