From bb070679f708f6fac6123446b2a7bda8afe0408f Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:21:29 -0700 Subject: [PATCH] feat: support vendored Typer Click dialects Closes #107 --- .github/workflows/compatibility.yml | 25 +++ CHANGELOG.md | 3 + compatibility/README.md | 4 + .../consumers/atlas_click/pyproject.toml | 2 +- .../consumers/beacon_typer/README.md | 3 +- .../consumers/beacon_typer/pyproject.toml | 2 +- .../cinder_automation/pyproject.toml | 2 +- docs/adopter-readiness.md | 4 +- docs/api-stability.md | 2 +- docs/typer-adapter.md | 16 +- examples/typer_app/README.md | 3 +- examples/typer_app/pyproject.toml | 2 +- lib/python/base_cli/_click_compat.py | 174 ++++++++++++++++++ lib/python/base_cli/app.py | 4 +- lib/python/base_cli/typer.py | 15 +- pyproject.toml | 6 +- tests/test_typer_adapter.py | 28 ++- 17 files changed, 267 insertions(+), 28 deletions(-) create mode 100644 lib/python/base_cli/_click_compat.py diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index 8774e77..a4f7ffa 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -12,6 +12,31 @@ concurrency: cancel-in-progress: true jobs: + typer-matrix: + name: Typer ${{ matrix.typer-version }} / Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + typer-version: ["0.25.1", "0.26.0", "0.27.1"] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python-version }} + - name: Install framework and exact Typer line + run: | + python -m pip install "setuptools<77" "wheel" + python -m pip install ".[dev]" + python -m pip install --upgrade "typer==${{ matrix.typer-version }}" + - name: Install Beacon fixture without dependency resolution + run: python -m pip install --no-deps compatibility/consumers/beacon_typer + - name: Run adapter and Beacon compatibility tests + run: python -m pytest tests/test_typer_adapter.py compatibility/consumers/beacon_typer/tests + downstream: name: Install and test independent consumers runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index c61a1e0..0006426 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,9 @@ the API stability policy and migration guide before upgrading from `0.3.x`. API stability policy, compatibility suite, and production-adoption guidance. - Add a version, license, install, and release-notes strip to the README and validate it against the repository version contract. +- Extend the optional Typer adapter through Typer 0.27.x by selecting the + command tree's matching public or vendored Click dialect, with a Python 3.10 + through 3.14 compatibility matrix covering Typer 0.25.1, 0.26.0, and 0.27.1. ### Planned diff --git a/compatibility/README.md b/compatibility/README.md index 026d33f..75f6a68 100644 --- a/compatibility/README.md +++ b/compatibility/README.md @@ -20,6 +20,10 @@ own dependencies, and runs each consumer's tests. This catches import, packaging, adapter, and contract regressions without relying on repository source imports. +The same workflow runs the Typer adapter and Beacon fixture against Typer +0.25.1, 0.26.0, and 0.27.1 on Python 3.10 through 3.14. This matrix covers the +transition from Click's public command classes to Typer's vendored Click fork. + Run the same checks locally: ```bash diff --git a/compatibility/consumers/atlas_click/pyproject.toml b/compatibility/consumers/atlas_click/pyproject.toml index a2ccbfb..ea1d77b 100644 --- a/compatibility/consumers/atlas_click/pyproject.toml +++ b/compatibility/consumers/atlas_click/pyproject.toml @@ -7,7 +7,7 @@ name = "base-cli-compat-consumer-atlas" version = "0.1.0" description = "Independent Click consumer compatibility fixture for base-cli" requires-python = ">=3.10" -dependencies = ["base-cli>=0.3,<0.4", "click>=8.1"] +dependencies = ["base-cli>=0.3,<0.5", "click>=8.1"] [project.scripts] atlas-consumer = "atlas_click.cli:main" diff --git a/compatibility/consumers/beacon_typer/README.md b/compatibility/consumers/beacon_typer/README.md index 0e29d51..5dcd9c0 100644 --- a/compatibility/consumers/beacon_typer/README.md +++ b/compatibility/consumers/beacon_typer/README.md @@ -7,4 +7,5 @@ validation, and callback behavior while adding the `base-cli` framework lifecycl Install with `python -m pip install .`, run `beacon-consumer --help`, and execute `beacon-consumer --quiet deploy --service api --replicas 3`. The supported Typer range is explicit in package metadata and is exercised by the compatibility -workflow. Run `python -m pytest tests` to repeat the downstream tests locally. +workflow across Typer 0.25.1, 0.26.0, and 0.27.1 on Python 3.10 through 3.14. +Run `python -m pytest tests` to repeat the downstream tests locally. diff --git a/compatibility/consumers/beacon_typer/pyproject.toml b/compatibility/consumers/beacon_typer/pyproject.toml index 5524ba3..2522c82 100644 --- a/compatibility/consumers/beacon_typer/pyproject.toml +++ b/compatibility/consumers/beacon_typer/pyproject.toml @@ -7,7 +7,7 @@ name = "base-cli-compat-consumer-beacon" version = "0.1.0" description = "Independent Typer consumer compatibility fixture for base-cli" requires-python = ">=3.10" -dependencies = ["base-cli>=0.3,<0.4", "typer>=0.12,<0.26"] +dependencies = ["base-cli>=0.3,<0.5", "typer>=0.12,<0.28"] [project.scripts] beacon-consumer = "beacon_typer.cli:main" diff --git a/compatibility/consumers/cinder_automation/pyproject.toml b/compatibility/consumers/cinder_automation/pyproject.toml index 2160411..955afb0 100644 --- a/compatibility/consumers/cinder_automation/pyproject.toml +++ b/compatibility/consumers/cinder_automation/pyproject.toml @@ -7,7 +7,7 @@ name = "base-cli-compat-consumer-cinder" version = "0.1.0" description = "Independent automation consumer compatibility fixture for base-cli" requires-python = ">=3.10" -dependencies = ["base-cli>=0.3,<0.4", "click>=8.1"] +dependencies = ["base-cli>=0.3,<0.5", "click>=8.1"] [project.optional-dependencies] observability = ["opentelemetry-api>=1.24,<2"] diff --git a/docs/adopter-readiness.md b/docs/adopter-readiness.md index a9232ef..9e0bf19 100644 --- a/docs/adopter-readiness.md +++ b/docs/adopter-readiness.md @@ -93,14 +93,14 @@ guardrail: | Friction found during onboarding | Linked issue | Disposition | | --- | --- | --- | | Existing nested/lazy Click trees need lifecycle attachment | [#57](https://github.com/basefoundry/base-cli/issues/57) | Resolved; Atlas fixture remains a regression check. | -| Typed consumers need a supported adapter and version boundary | [#61](https://github.com/basefoundry/base-cli/issues/61) | Resolved; Beacon pins Typer `<0.26` and tests the boundary. | +| Typed consumers need a supported adapter and version boundary | [#61](https://github.com/basefoundry/base-cli/issues/61) | Resolved; Beacon exercises the public and vendored Click boundaries across the supported Typer matrix. | | Automation needs a stable machine contract | [#63](https://github.com/basefoundry/base-cli/issues/63) | Resolved; Cinder asserts JSON output and error behavior. | | Teams need repeatable wheel, platform, and downstream checks | [#67](https://github.com/basefoundry/base-cli/issues/67), [#68](https://github.com/basefoundry/base-cli/issues/68) | Resolved; compatibility workflow is retained. | | Adoption requires explicit API, migration, and security expectations | [#69](https://github.com/basefoundry/base-cli/issues/69), [#70](https://github.com/basefoundry/base-cli/issues/70) | Resolved; this checklist links the published policies. | | Teams need copy-pasteable production examples | [#71](https://github.com/basefoundry/base-cli/issues/71) | Resolved; four installable examples remain in CI. | There are no unresolved release-blocking findings in the three fixture -baseline. The accepted residuals are the documented Typer `<0.26` support +baseline. The accepted residuals are the documented Typer `<0.28` support window, consumer-owned configuration/schema policy, and the absence of a permissioned public customer case study. Any new blocker must be filed as a linked issue before release and either fixed or explicitly accepted in the diff --git a/docs/api-stability.md b/docs/api-stability.md index 96e22b8..67363f1 100644 --- a/docs/api-stability.md +++ b/docs/api-stability.md @@ -50,7 +50,7 @@ that need a frozen API should pin a minor release (for example, `~=0.4.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 +versioned and constrained in `pyproject.toml`: Typer `>=0.12,<0.28`, 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 diff --git a/docs/typer-adapter.md b/docs/typer-adapter.md index e3920fb..19d7872 100644 --- a/docs/typer-adapter.md +++ b/docs/typer-adapter.md @@ -7,10 +7,12 @@ without making Typer a core dependency: python -m pip install 'base-cli[typer]' ``` -The extra currently supports Typer 0.12 through 0.25. Typer 0.26 and later -ship a private Click fork; the adapter will reject those command objects until -base-cli can provide an equivalent compatibility boundary without changing the -core Click integration. +The extra supports Typer 0.12 through 0.27.x. Typer 0.26 and later ship a +private Click fork, so the adapter selects the Click dialect that owns the +generated command tree. Lifecycle options, parameter types, command +instrumentation, and exception handling are always created and interpreted by +that same dialect; base-cli never mixes public Click objects into a vendored +Typer tree. Use `attach_typer()` at the same boundary where a Click app would use `attach()`: @@ -37,7 +39,7 @@ if __name__ == "__main__": ``` The adapter calls Typer's supported command materializer and returns that same -Click command object. Typer remains responsible for command decorators, +Click command object. Typer remains responsible for command decorators, typed parameters, nested apps, dependency injection, help, completion, and Typer/Click exceptions. Base-cli adds its normal lifecycle options, context, logging, redaction, runtime state, cleanup, and outcome handling. @@ -65,3 +67,7 @@ command = adapter.attach(name="example") Typer is an optional extra and is imported lazily. Importing `base_cli` and using the Click integration never imports or requires Typer. + +The compatibility workflow exercises the adapter and a typed consumer against +Typer 0.25.1, 0.26.0, and 0.27.1 on Python 3.10 through 3.14. Keep this matrix +green before widening the supported Typer range again. diff --git a/examples/typer_app/README.md b/examples/typer_app/README.md index 3aa3508..0be0547 100644 --- a/examples/typer_app/README.md +++ b/examples/typer_app/README.md @@ -59,6 +59,7 @@ publishing. Announce adapter support-range changes as release notes. ## Operational troubleshooting - Run with `--debug` and capture `--log-file` when diagnosing a command. -- If help fails after a Typer upgrade, check that the version is below 0.26. +- If help fails after a Typer upgrade, check that the version is within the + supported `>=0.12,<0.28` range. - Use `--count 1` to distinguish application failures from input validation. - Redact access codes and other credentials from issue reports and transcripts. diff --git a/examples/typer_app/pyproject.toml b/examples/typer_app/pyproject.toml index 6018092..652ccb0 100644 --- a/examples/typer_app/pyproject.toml +++ b/examples/typer_app/pyproject.toml @@ -9,7 +9,7 @@ description = "Typed Typer base-cli reference application" requires-python = ">=3.10" dependencies = [ "base-cli>=0.3", - "typer>=0.12,<0.26", + "typer>=0.12,<0.28", ] [project.scripts] diff --git a/lib/python/base_cli/_click_compat.py b/lib/python/base_cli/_click_compat.py new file mode 100644 index 0000000..fb1ac81 --- /dev/null +++ b/lib/python/base_cli/_click_compat.py @@ -0,0 +1,174 @@ +"""Click dialect selection for third-party command trees. + +The native base-cli command builder uses the public :mod:`click` package. A +recent Typer release intentionally vendors its own Click fork, so a Typer +command tree must be extended with parameters and exceptions from that same +fork. This module keeps that boundary in one place instead of spreading +``isinstance`` checks and private Typer imports through the lifecycle code. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + + +_DIALECT_ATTRIBUTE = "__base_cli_click_dialect__" + + +@dataclass(frozen=True) +class _VendoredClickDialect: + """The small Click surface required by the attached lifecycle.""" + + module: Any + Command: Any + Option: Any + Path: Any + version_option: Callable[..., Any] + exceptions: Any + Abort: Any + UsageError: Any + ClickException: Any + + def __getattr__(self, name: str) -> Any: + return getattr(self.module, name) + + +def _vendor_version_option_factory( + typer_option: Any, + echo: Callable[..., Any], +) -> Callable[..., Any]: + """Build the Click ``version_option`` decorator for Typer's fork.""" + + def version_option( + version: str | None = None, + *param_decls: str, + package_name: str | None = None, + prog_name: str | None = None, + message: str | None = None, + **kwargs: Any, + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + del package_name, prog_name + if version is None: + raise RuntimeError("A version is required for the Typer adapter.") + if message is None: + message = "%(prog)s, version %(version)s" + if not param_decls: + param_decls = ("--version",) + kwargs.setdefault("is_flag", True) + kwargs.setdefault("expose_value", False) + kwargs.setdefault("is_eager", True) + + def callback(ctx: Any, _param: Any, value: bool) -> None: + if not value or getattr(ctx, "resilient_parsing", False): + return + root_name = getattr(ctx.find_root(), "info_name", None) or "cli" + rendered = message % { + "prog": root_name, + "package": "base-cli", + "version": version, + } + echo(rendered, color=getattr(ctx, "color", None)) + ctx.exit() + + def decorator(source: Callable[..., Any]) -> Callable[..., Any]: + parameter = typer_option( + param_decls=list(param_decls), + callback=callback, + **kwargs, + ) + params = list(getattr(source, "__click_params__", ())) + params.append(parameter) + setattr(source, "__click_params__", params) + return source + + return decorator + + return version_option + + +def _vendored_typer_dialect(typer: Any) -> _VendoredClickDialect | None: + module = getattr(typer, "_click", None) + if module is None or not hasattr(module, "Command"): + return None + try: + from typer.core import TyperOption + from typer.models import TyperPath + except (ImportError, AttributeError): + return None + + core = module.core + exceptions = module.exceptions + + def option(param_decls: list[str], **attrs: Any) -> Any: + return TyperOption(param_decls=list(param_decls), **attrs) + + return _VendoredClickDialect( + module=module, + Command=module.Command, + Option=option, + Path=TyperPath, + version_option=_vendor_version_option_factory(TyperOption, module.echo), + exceptions=exceptions, + Abort=getattr(module, "Abort", getattr(exceptions, "Abort", core.Abort)), + UsageError=getattr(module, "UsageError", core.UsageError), + ClickException=module.ClickException, + ) + + +def dialect_for_typer(typer: Any) -> Any: + """Return the Click implementation that owns a Typer command tree.""" + + import click + + dialect = _vendored_typer_dialect(typer) + return dialect if dialect is not None else click + + +def mark_command_dialect(command: Any, dialect: Any) -> Any: + """Remember the owning dialect on a generated command object.""" + + try: + setattr(command, _DIALECT_ATTRIBUTE, dialect) + except (AttributeError, TypeError): + # Click command instances are mutable in all supported releases. If a + # third-party command forbids attributes, class detection still works. + pass + return command + + +def dialect_for_command(command: Any) -> Any: + """Resolve the Click implementation that owns ``command``.""" + + marked = getattr(command, _DIALECT_ATTRIBUTE, None) + if marked is not None: + return marked + + import click + + if isinstance(command, click.Command): + return click + + try: + import typer + except ImportError: + return click + dialect = _vendored_typer_dialect(typer) + if dialect is not None and isinstance(command, dialect.Command): + mark_command_dialect(command, dialect) + return dialect + return click + + +def is_command(command: Any) -> bool: + """Return whether a command belongs to a supported Click dialect.""" + + import click + + if isinstance(command, click.Command): + return True + dialect = dialect_for_command(command) + return dialect is not click and isinstance(command, dialect.Command) + + +__all__ = ["dialect_for_command", "dialect_for_typer", "is_command", "mark_command_dialect"] diff --git a/lib/python/base_cli/app.py b/lib/python/base_cli/app.py index 5a056f0..d95a2e5 100644 --- a/lib/python/base_cli/app.py +++ b/lib/python/base_cli/app.py @@ -18,6 +18,7 @@ from threading import RLock from typing import Any, Callable, ParamSpec, TypeVar +from ._click_compat import dialect_for_command from ._lifecycle import ( InvocationOutcome, RunRecorder, @@ -676,7 +677,7 @@ def attach( base-cli extends its root parameters and adds one lifecycle boundary. """ - click = _require_click() + click = dialect_for_command(command) if not isinstance(command, click.Command): raise TypeError("App.attach() requires a click.Command instance.") _reject_async_callback(getattr(command, "callback", None)) @@ -2864,6 +2865,7 @@ def run_app( display_command = app.profile.display_command() invocation_argv = _effective_invocation_argv(app, args, explicit_argv, display_command) command = app.click_command + click = dialect_for_command(command) invocation_token = _INVOCATION_ARGV.set(invocation_argv) try: bypass_token = _INVOCATION_MAIN_BYPASS.set(command) diff --git a/lib/python/base_cli/typer.py b/lib/python/base_cli/typer.py index 52dc885..3f4867b 100644 --- a/lib/python/base_cli/typer.py +++ b/lib/python/base_cli/typer.py @@ -13,15 +13,15 @@ from collections.abc import Iterable from typing import Any, Callable, TYPE_CHECKING +from ._click_compat import dialect_for_typer, mark_command_dialect from .app import App, attach from .context import Context if TYPE_CHECKING: - import click import typer _TyperApp = typer.Typer - _ClickCommand = click.Command + _ClickCommand = Any else: _TyperApp = Any _ClickCommand = Any @@ -64,16 +64,13 @@ def get_typer_command(typer_app: _TyperApp) -> _ClickCommand: ) from exc command = get_command(typer_app) - # Importing Click here keeps this module optional while ensuring a clear - # failure if a third-party Typer distribution returns an incompatible tree. - import click - - if not isinstance(command, click.Command): + dialect = dialect_for_typer(typer) + if not isinstance(command, dialect.Command): raise TypeError( "Typer did not produce a Click command; upgrade to a supported " - "Typer release (currently 0.12 through 0.25)." + "Typer release (currently 0.12 through 0.27.x)." ) - return command + return mark_command_dialect(command, dialect) def _name_command_for_lifecycle( diff --git a/pyproject.toml b/pyproject.toml index 82efb87..ae9a162 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,9 +43,9 @@ dev = [ "pytest>=8.0", ] typer = [ - # Typer 0.26+ ships a private Click fork; base-cli intentionally supports - # the public Click command classes through Typer 0.25.x for now. - "typer>=0.12,<0.26", + # Typer 0.26+ ships a private Click fork; the adapter selects the matching + # Click dialect so both pre-fork and vendored-Typer command trees work. + "typer>=0.12,<0.28", ] rich = [ "rich>=13.7,<15", diff --git a/tests/test_typer_adapter.py b/tests/test_typer_adapter.py index 75fb464..cd413a3 100644 --- a/tests/test_typer_adapter.py +++ b/tests/test_typer_adapter.py @@ -40,7 +40,10 @@ def greet( sensitive_parameters={"access_code"}, ) - self.assertIsInstance(command, __import__("click").Command) + self.assertTrue(callable(command.main)) + vendor_click = getattr(self.typer, "_click", None) + if vendor_click is not None: + self.assertIsInstance(command, vendor_click.Command) self.assertEqual(command.name, "typer-cli") with tempfile.TemporaryDirectory() as home: result = base_cli.testing.invoke( @@ -106,6 +109,29 @@ def status() -> None: self.assertIs(command, adapter.command) self.assertEqual(command.name, "cached-cli") + def test_adapter_uses_owner_dialect_for_version_option(self) -> None: + cli = self.typer.Typer() + + @cli.command() + def status() -> None: + self.typer.echo("ready") + + command = base_cli.attach_typer( + cli, + name="versioned-cli", + version="9.8.7", + log_to_file=False, + ) + with tempfile.TemporaryDirectory() as home: + result = base_cli.testing.invoke( + command, + ["--version"], + home=Path(home), + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn("versioned-cli, version 9.8.7", result.stdout) + def test_unnamed_multi_command_requires_a_lifecycle_name(self) -> None: cli = self.typer.Typer()