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
25 changes: 25 additions & 0 deletions .github/workflows/compatibility.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions compatibility/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion compatibility/consumers/atlas_click/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion compatibility/consumers/beacon_typer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion compatibility/consumers/beacon_typer/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion compatibility/consumers/cinder_automation/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
4 changes: 2 additions & 2 deletions docs/adopter-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/api-stability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 11 additions & 5 deletions docs/typer-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`:
Expand All @@ -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.
Expand Down Expand Up @@ -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.
3 changes: 2 additions & 1 deletion examples/typer_app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion examples/typer_app/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
174 changes: 174 additions & 0 deletions lib/python/base_cli/_click_compat.py
Original file line number Diff line number Diff line change
@@ -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"]
4 changes: 3 additions & 1 deletion lib/python/base_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand Down
Loading