Skip to content

Commit 9ca76a0

Browse files
authored
Publish API stability and deprecation policy (#69) (#95)
## Summary - publish the public API stability, SemVer, support-window, and experimental-namespace policy - add a typed `base_cli.deprecated()` decorator and `BaseCliDeprecationWarning` - add migration guidance and contract tests for facade exports, protocol framing, and JSON v1 schemas ## Why Adopters need explicit compatibility promises and a predictable deprecation path before depending on base-cli in professional tooling. The policy makes the pre-1.0 boundary clear while documenting the stricter 1.0 contract, and the tests turn the public surface and versioned schemas into release gates. Closes #69 ## Validation - `./tests/validate.sh` - `uv run --extra dev --extra typer pytest -q` - `ruff format --check scripts examples` - `ruff check lib/python/base_cli scripts examples tests` - `mypy --strict examples/typed_consumer.py` - `python scripts/validate_docs.py` - `python scripts/benchmark_runtime.py --check` - `pytest --cov=base_cli --cov-fail-under=80` (84.1%) - `bandit -q -r lib/python/base_cli scripts -lll -iii` - `pip-audit --strict`
1 parent bcdf058 commit 9ca76a0

9 files changed

Lines changed: 429 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and versions are tracked in the repo-root `VERSION` file.
99

1010
### Added
1111

12+
- Add the public API stability and deprecation policy, migration guide, and
13+
`base_cli.deprecated()` warning helper with contract-test guardrails.
1214
- Add immutable `LifecycleOptions` and `LifecycleOption` policies for enabling,
1315
disabling, renaming, and configuring each standard option independently, with
1416
normalized `LifecycleValues` stored in namespaced Click metadata without

‎README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ Automation-facing JSON output, errors, and logs are opt-in through the
4141
versioned contracts documented in [`docs/json-contracts.md`](docs/json-contracts.md).
4242
Human output and Click error behavior remain the default.
4343

44+
The supported public facade, compatibility promises, deprecation warning
45+
mechanism, and migration requirements are documented in
46+
[`docs/api-stability.md`](docs/api-stability.md) and
47+
[`docs/migrations.md`](docs/migrations.md).
48+
4449
Shared record renderers keep machine output stable: CSV and TSV stream
4550
one-pass iterables without headers or footers, while terminal tables account
4651
for Unicode display width and safely truncate oversized cells. See

‎docs/api-stability.md‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# API stability and deprecation policy
2+
3+
This document is the compatibility contract for `base-cli`. It applies to the
4+
current pre-1.0 releases and will be updated before the 1.0 release. The
5+
repository's contract tests and release checklist are expected to change with
6+
this document, so a policy change is itself a reviewed, changelogged change.
7+
8+
## Public surface
9+
10+
The supported Python facade is `import base_cli`. The names in
11+
`base_cli.__all__` are the public facade; documented names in the explicitly
12+
exported modules (`base_cli.command_protocol`, `base_cli.json_contracts`, and
13+
the other modules listed in their module-level `__all__`) are public as well.
14+
Names beginning with `_`, modules not documented here, and implementation
15+
details are private and may change without notice.
16+
17+
The following machine-facing contracts are public and versioned:
18+
19+
- command framing defaults to `COMMAND_PROTOCOL_V1`; record schemas are owned
20+
and registered by the consumer;
21+
- JSON output, error, and log records use `schema_version: 1` and the schema
22+
identifiers documented in [`json-contracts.md`](json-contracts.md); and
23+
- the exported callable and type signatures exercised by the public API tests.
24+
25+
Human-readable messages, table layout, log wording, temporary directory names,
26+
and history implementation details are not stable machine interfaces unless a
27+
separate contract document says otherwise. Consumers that need automation
28+
should select the JSON or record protocol contracts.
29+
30+
`base_cli.experimental` is reserved for preview APIs. No experimental symbols
31+
are currently shipped. A future preview must live under that namespace, be
32+
labelled experimental in its documentation, and must not be re-exported from
33+
the stable facade until it is promoted.
34+
35+
## Versioning and compatibility
36+
37+
Starting with 1.0, `base-cli` follows Semantic Versioning:
38+
39+
- a **MAJOR** release may remove or change public APIs and versioned contracts;
40+
- a **MINOR** release adds backwards-compatible functionality and may begin a
41+
deprecation; and
42+
- a **PATCH** release contains compatible fixes, security fixes, and docs.
43+
44+
Until 1.0, the leading zero is meaningful: patch releases remain compatible,
45+
while a minor release is a compatibility boundary and may contain a breaking
46+
change. We still prefer additive changes, and any pre-1.0 break must include a
47+
warning where practical, a migration path, and a changelog entry. Consumers
48+
that need a frozen API should pin a minor release (for example, `~=0.3.0`).
49+
50+
The core package requires Python `>=3.10` and currently tests CPython 3.10
51+
through 3.14 on Linux, macOS, and Windows. Core runtime dependencies are
52+
Click `>=8.1` and PyYAML `>=6.0`. Optional integrations are independently
53+
versioned and constrained in `pyproject.toml`: Typer `>=0.12,<0.26`, Rich
54+
`>=13.7,<15`, and OpenTelemetry API `>=1.24,<2`. The lower bounds are the
55+
minimum supported versions; a dependency major release is supported after it
56+
passes the compatibility suite. A future minor release may drop an end-of-life
57+
Python or dependency window with a migration note.
58+
59+
Platform tier details and the operating-system support test matrix are kept in
60+
[`platform-support.md`](platform-support.md).
61+
62+
## Deprecation process
63+
64+
Use the public `base_cli.deprecated` decorator for callable APIs:
65+
66+
```python
67+
from base_cli import deprecated
68+
69+
70+
@deprecated("0.3", remove="1.0", alternative="new_name")
71+
def old_name(value: str) -> str:
72+
return new_name(value)
73+
```
74+
75+
The decorator preserves the callable's metadata and behavior, and emits a
76+
`BaseCliDeprecationWarning` with `stacklevel=2` on every call. Applications can
77+
show or fail on these warnings with the standard `warnings` filters. The
78+
warning identifies the release that introduced the deprecation, the planned
79+
removal release, and (when available) the replacement API.
80+
81+
Every deprecation must:
82+
83+
1. remain supported for at least **two minor releases and 90 calendar days,
84+
whichever is longer**;
85+
2. include a migration note in [`migrations.md`](migrations.md) or the relevant
86+
contract document;
87+
3. appear in `CHANGELOG.md` under the release that introduces the warning and
88+
the release that removes the API; and
89+
4. be removed only in the stated removal release (or a later release), except
90+
for an urgent security or legal fix that is explicitly documented.
91+
92+
The removal PR must delete the contract test for the old symbol only after the
93+
replacement and migration guidance are present. A deprecation is not complete
94+
until the warning, docs, tests, and changelog agree.
95+
96+
## Contract guardrails
97+
98+
`tests/test_public_api.py` verifies that every facade export is resolvable,
99+
that module `__all__` surfaces do not silently drift, and that private legacy
100+
names stay absent. `tests/test_api_stability.py` additionally verifies the
101+
warning behavior, the default command-protocol header, and the JSON v1 schema
102+
identifiers and envelope fields. Changes to an exported symbol or a versioned
103+
schema therefore require an intentional test and documentation update before
104+
release.
105+
106+
When a schema must evolve incompatibly, add a new version and an adapter rather
107+
than changing the meaning of an existing field in place. Keep the old version
108+
available for the same deprecation window and document the migration.
109+
110+
## Release checklist
111+
112+
Before publishing a release, maintainers should confirm:
113+
114+
- the supported Python and dependency windows still match CI and `pyproject.toml`;
115+
- public exports and schema constants have contract-test coverage;
116+
- each deprecation has a warning, removal target, migration note, and changelog
117+
entry; and
118+
- the release notes call out any pre-1.0 compatibility boundary or contract
119+
version addition.
120+
121+
See [`releasing.md`](releasing.md) for the mechanical package and publishing
122+
steps.

‎docs/migrations.md‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Migration guide
2+
3+
This page collects the format for changes that affect the public `base-cli`
4+
contract. The compatibility rules and deprecation timeline are defined in
5+
[`api-stability.md`](api-stability.md).
6+
7+
## Migrating a deprecated API
8+
9+
1. Upgrade to the first release that emits the warning.
10+
2. Replace the old symbol with the alternative named in the warning and in the
11+
release notes.
12+
3. Run tests with `BaseCliDeprecationWarning` enabled so no old call sites are
13+
missed:
14+
15+
```python
16+
import warnings
17+
18+
from base_cli import BaseCliDeprecationWarning
19+
20+
warnings.simplefilter("error", BaseCliDeprecationWarning)
21+
```
22+
23+
4. Remove temporary compatibility shims before the stated removal release.
24+
25+
Warnings are ordinary Python warnings, so applications can instead record or
26+
display them with their normal `warnings` configuration.
27+
28+
## Schema migrations
29+
30+
Versioned JSON envelopes and command framing must not change meaning in place.
31+
Add a new schema or protocol version, keep the old version during its support
32+
window, and provide an adapter when practical. A schema migration note should
33+
include:
34+
35+
- old and new version identifiers;
36+
- field additions, removals, and type changes;
37+
- producer and consumer rollout order; and
38+
- the release where the old version will stop being accepted.
39+
40+
## Migration note template
41+
42+
```markdown
43+
### `old_name` → `new_name`
44+
45+
- **First warning:** 0.x.y
46+
- **Removal target:** 1.0.0
47+
- **Why:** explain the problem in one sentence.
48+
- **Action:** show the smallest before/after example.
49+
- **Compatibility:** describe the temporary adapter or schema version.
50+
```
51+
52+
For release mechanics, see [`releasing.md`](releasing.md). For the public
53+
surface and deprecation requirements, see [`api-stability.md`](api-stability.md).

‎lib/python/base_cli/__init__.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,15 @@ def _resolve_version() -> str:
3030

3131
__version__ = _resolve_version()
3232

33-
from . import command_filters, command_protocol, extensions, history, integrations, json_contracts, testing
33+
from . import command_filters, command_protocol, deprecations, extensions, history, integrations, json_contracts, testing
3434
from .attachment import (
3535
AttachmentAdapter,
3636
AttachmentContextFactory,
3737
AttachmentContract,
3838
AttachmentServiceFactory,
3939
)
4040
from .config import BatteriesIncludedConfigLoader, ConfigSnapshot, FrameworkConfig
41+
from .deprecations import BaseCliDeprecationWarning, deprecated
4142
from .app import (
4243
App,
4344
argument,
@@ -143,6 +144,7 @@ def _resolve_version() -> str:
143144
"AttachmentServiceFactory",
144145
"TyperAdapter",
145146
"BatteriesIncludedConfigLoader",
147+
"BaseCliDeprecationWarning",
146148
"BOOLEAN",
147149
"ApplicationStateT",
148150
"CliProfile",
@@ -179,6 +181,7 @@ def _resolve_version() -> str:
179181
"command_filters",
180182
"command_matches",
181183
"command_protocol",
184+
"deprecations",
182185
"json_contracts",
183186
"JSON_CONTRACT_VERSION",
184187
"JSON_ERROR_SCHEMA",
@@ -202,6 +205,7 @@ def _resolve_version() -> str:
202205
"command",
203206
"configure_logger",
204207
"delegated_display_command",
208+
"deprecated",
205209
"get_command_app",
206210
"get_current_context",
207211
"get_typer_command",
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Small, consistent deprecation primitives for public base-cli APIs."""
2+
3+
from __future__ import annotations
4+
5+
import warnings
6+
from collections.abc import Callable
7+
from functools import wraps
8+
from typing import ParamSpec, TypeVar
9+
10+
11+
P = ParamSpec("P")
12+
R = TypeVar("R")
13+
14+
15+
class BaseCliDeprecationWarning(DeprecationWarning):
16+
"""Warning emitted when a supported base-cli API is being retired."""
17+
18+
19+
def deprecated(
20+
since: str,
21+
*,
22+
remove: str,
23+
alternative: str | None = None,
24+
) -> Callable[[Callable[P, R]], Callable[P, R]]:
25+
"""Mark a callable as deprecated while preserving its normal behavior.
26+
27+
``since`` and ``remove`` are release identifiers, not free-form dates. The
28+
decorator emits :class:`BaseCliDeprecationWarning` on every call so an
29+
application can choose whether to show, record, or fail on the warning.
30+
``stacklevel=2`` points the warning at the caller rather than this helper.
31+
"""
32+
33+
if not since.strip():
34+
raise ValueError("since must not be empty")
35+
if not remove.strip():
36+
raise ValueError("remove must not be empty")
37+
38+
def decorate(function: Callable[P, R]) -> Callable[P, R]:
39+
message = f"{function.__qualname__} is deprecated since {since} and will be removed in {remove}."
40+
if alternative:
41+
message += f" Use {alternative} instead."
42+
43+
@wraps(function)
44+
def wrapped(*args: P.args, **kwargs: P.kwargs) -> R:
45+
warnings.warn(message, BaseCliDeprecationWarning, stacklevel=2)
46+
return function(*args, **kwargs)
47+
48+
return wrapped
49+
50+
return decorate
51+
52+
53+
__all__ = ["BaseCliDeprecationWarning", "deprecated"]

0 commit comments

Comments
 (0)