From c83e502b1ba4b791f66dabd83a8433c822091efe Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:06:10 -0700 Subject: [PATCH] docs: publish API stability policy (#69) --- CHANGELOG.md | 2 + README.md | 5 + docs/api-stability.md | 122 +++++++++++++++++++ docs/migrations.md | 53 ++++++++ lib/python/base_cli/__init__.py | 6 +- lib/python/base_cli/deprecations.py | 53 ++++++++ tests/test_api_stability.py | 182 ++++++++++++++++++++++++++++ tests/test_public_api.py | 5 + tests/validate.sh | 2 + 9 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 docs/api-stability.md create mode 100644 docs/migrations.md create mode 100644 lib/python/base_cli/deprecations.py create mode 100644 tests/test_api_stability.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 542752c..6be4b68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 26546c2..7a65cf9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/api-stability.md b/docs/api-stability.md new file mode 100644 index 0000000..1f1b190 --- /dev/null +++ b/docs/api-stability.md @@ -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. diff --git a/docs/migrations.md b/docs/migrations.md new file mode 100644 index 0000000..c3d8f6d --- /dev/null +++ b/docs/migrations.md @@ -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). diff --git a/lib/python/base_cli/__init__.py b/lib/python/base_cli/__init__.py index e9e32af..af8ee59 100644 --- a/lib/python/base_cli/__init__.py +++ b/lib/python/base_cli/__init__.py @@ -30,7 +30,7 @@ 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, @@ -38,6 +38,7 @@ def _resolve_version() -> str: AttachmentServiceFactory, ) from .config import BatteriesIncludedConfigLoader, ConfigSnapshot, FrameworkConfig +from .deprecations import BaseCliDeprecationWarning, deprecated from .app import ( App, argument, @@ -143,6 +144,7 @@ def _resolve_version() -> str: "AttachmentServiceFactory", "TyperAdapter", "BatteriesIncludedConfigLoader", + "BaseCliDeprecationWarning", "BOOLEAN", "ApplicationStateT", "CliProfile", @@ -179,6 +181,7 @@ def _resolve_version() -> str: "command_filters", "command_matches", "command_protocol", + "deprecations", "json_contracts", "JSON_CONTRACT_VERSION", "JSON_ERROR_SCHEMA", @@ -202,6 +205,7 @@ def _resolve_version() -> str: "command", "configure_logger", "delegated_display_command", + "deprecated", "get_command_app", "get_current_context", "get_typer_command", diff --git a/lib/python/base_cli/deprecations.py b/lib/python/base_cli/deprecations.py new file mode 100644 index 0000000..56d327a --- /dev/null +++ b/lib/python/base_cli/deprecations.py @@ -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"] diff --git a/tests/test_api_stability.py b/tests/test_api_stability.py new file mode 100644 index 0000000..1636605 --- /dev/null +++ b/tests/test_api_stability.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import warnings +import unittest + +import base_cli +from base_cli import command_protocol, json_contracts + + +EXPECTED_FACADE_EXPORTS = frozenset( + { + "App", + "__version__", + "AttachmentAdapter", + "AttachmentContextFactory", + "AttachmentContract", + "AttachmentServiceFactory", + "TyperAdapter", + "BatteriesIncludedConfigLoader", + "BaseCliDeprecationWarning", + "BOOLEAN", + "ApplicationStateT", + "CliProfile", + "ConfigLoader", + "ConfigSnapshot", + "CommandFilterNormalizer", + "CommandCodec", + "CommandProtocolError", + "CommandSchemaRegistry", + "ConfigurationError", + "COMMAND_ENTRY_POINT_GROUP", + "Context", + "ConfigT", + "ENTRY_POINT_GROUPS", + "ExtensionCollisionError", + "ExtensionDescriptor", + "ExtensionDiscovery", + "ExtensionDiscoveryError", + "ExtensionLoadError", + "ExtensionLoadResult", + "ExtensionsDisabledError", + "DEFAULT_SCHEMA_REGISTRY", + "DisplayCommandResolver", + "EnvironmentConfigLoader", + "ExitCode", + "FieldSpec", + "FrameworkConfig", + "LIFECYCLE_META_KEY", + "LifecycleOption", + "LifecycleOptions", + "LifecycleValues", + "NULLABLE_STRING", + "STRING", + "command_filters", + "command_matches", + "command_protocol", + "deprecations", + "json_contracts", + "JSON_CONTRACT_VERSION", + "JSON_ERROR_SCHEMA", + "JSON_LOG_SCHEMA", + "JSON_OUTPUT_SCHEMA", + "JsonLogFormatter", + "MAX_JSON_LOG_MESSAGE_LENGTH", + "dumps_envelope", + "dumps_record", + "dumps_records", + "error_envelope", + "extensions", + "history", + "integrations", + "inspection_envelope", + "render_inspection_json", + "testing", + "argument", + "attach", + "attach_typer", + "command", + "configure_logger", + "delegated_display_command", + "deprecated", + "get_command_app", + "get_current_context", + "get_typer_command", + "get_lifecycle_values", + "log_critical", + "log_debug", + "log_error", + "log_info", + "log_warning", + "loads_records", + "normalize_command_filter", + "normalize_command_filters", + "OutputFormatError", + "PUBLIC_OUTPUT_FORMATS", + "ProjectInfo", + "ProjectDiscovery", + "RECORD_SCHEMAS", + "RuntimeLayout", + "PLUGIN_ENTRY_POINT_GROUP", + "PROFILE_ENTRY_POINT_GROUP", + "RetentionPolicy", + "RuntimeResolver", + "is_terminal", + "output_format_choices", + "option", + "render_document", + "render_records", + "register_record_schema", + "redact_json_value", + "resolve_output_format", + "run_app", + "success_envelope", + "RuntimeBinding", + "ServicesT", + "TelemetryOptions", + "TelemetrySession", + "try_render_rich_table", + "HistoryWriter", + "HistoryDisplayResolver", + "UserConfigLoader", + "WorkspaceRootResolver", + } +) + + +class ApiStabilityTests(unittest.TestCase): + def test_facade_export_snapshot_is_resolvable(self) -> None: + self.assertEqual(set(base_cli.__all__), EXPECTED_FACADE_EXPORTS) + self.assertEqual(len(base_cli.__all__), len(set(base_cli.__all__))) + for name in base_cli.__all__: + self.assertTrue(hasattr(base_cli, name), name) + + def test_versioned_contract_identifiers_and_shapes_are_stable(self) -> None: + self.assertEqual(command_protocol.PROTOCOL_HEADER, "COMMAND_PROTOCOL_V1") + self.assertEqual(json_contracts.JSON_CONTRACT_VERSION, 1) + self.assertEqual( + { + json_contracts.JSON_OUTPUT_SCHEMA, + json_contracts.JSON_ERROR_SCHEMA, + json_contracts.JSON_LOG_SCHEMA, + }, + {"base-cli.output", "base-cli.error", "base-cli.log"}, + ) + + success = base_cli.success_envelope(run_id="run-1") + error = base_cli.error_envelope(run_id="run-1", code="bad", message="Nope") + expected_fields = {"schema_version", "schema", "code", "type", "message", "details", "run_id"} + self.assertEqual(set(success), expected_fields) + self.assertEqual(set(error), expected_fields) + self.assertEqual(success["schema_version"], 1) + self.assertEqual(error["schema_version"], 1) + + def test_deprecated_emits_actionable_warning_and_preserves_metadata(self) -> None: + @base_cli.deprecated("0.3", remove="1.0", alternative="new_name") + def old_name(value: str) -> str: + """Legacy implementation.""" + + return value.upper() + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", base_cli.BaseCliDeprecationWarning) + self.assertEqual(old_name("hello"), "HELLO") + + self.assertEqual(old_name.__name__, "old_name") + self.assertEqual(old_name.__doc__, "Legacy implementation.") + self.assertEqual(len(caught), 1) + self.assertIs(caught[0].category, base_cli.BaseCliDeprecationWarning) + self.assertIn("deprecated since 0.3", str(caught[0].message)) + self.assertIn("removed in 1.0", str(caught[0].message)) + self.assertIn("Use new_name instead", str(caught[0].message)) + self.assertEqual(caught[0].filename, __file__) + + def test_deprecated_requires_release_identifiers(self) -> None: + with self.assertRaises(ValueError): + base_cli.deprecated("", remove="1.0") + with self.assertRaises(ValueError): + base_cli.deprecated("0.3", remove=" ") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 70c7053..ce56f99 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -11,6 +11,7 @@ command_filters, command_protocol, config, + deprecations, history, json_contracts, lifecycle_options, @@ -47,6 +48,8 @@ def test_facade_exports_supported_modules_functions_and_types(self) -> None: "AttachmentAdapter", "AttachmentContract", "BatteriesIncludedConfigLoader", + "BaseCliDeprecationWarning", + "deprecated", "TyperAdapter", "attach_typer", "get_typer_command", @@ -79,6 +82,7 @@ def test_facade_exports_supported_modules_functions_and_types(self) -> None: self.assertIs(base_cli.command_protocol, command_protocol) self.assertIs(base_cli.typer, typer) self.assertIs(base_cli.json_contracts, json_contracts) + self.assertIs(base_cli.deprecations, deprecations) self.assertTrue(issubclass(base_cli.ConfigurationError, ValueError)) def test_module_all_surfaces_are_explicit(self) -> None: @@ -155,6 +159,7 @@ def test_module_all_surfaces_are_explicit(self) -> None: "redact_json_value", }, ) + self.assertEqual(set(deprecations.__all__), {"BaseCliDeprecationWarning", "deprecated"}) def test_entry_points_have_docstrings(self) -> None: self.assertTrue(base_cli.App.__doc__) diff --git a/tests/validate.sh b/tests/validate.sh index f33979c..9d2fac2 100755 --- a/tests/validate.sh +++ b/tests/validate.sh @@ -14,6 +14,8 @@ required_files=( .github/workflows/tests.yml .github/workflows/package.yml docs/releasing.md + docs/api-stability.md + docs/migrations.md MANIFEST.in scripts/validate_package_artifact.py scripts/validate_installed_package.py