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
4 changes: 2 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
with:
python-version: "3.x"
- name: Install test dependencies
run: python -m pip install ".[dev]"
run: python -m pip install ".[dev,typer]"
- name: Run Python tests
run: python -m pytest
- name: Type-check public contract sample
Expand Down Expand Up @@ -72,7 +72,7 @@ jobs:
fi
./tests/validate.sh
python3 -m venv /tmp/base-cli-venv
/tmp/base-cli-venv/bin/python -m pip install ".[dev]"
/tmp/base-cli-venv/bin/python -m pip install ".[dev,typer]"
/tmp/base-cli-venv/bin/python -m pytest
/tmp/base-cli-venv/bin/python -c "import base_cli; print(base_cli.__version__)"
'
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ and versions are tracked in the repo-root `VERSION` file.
- Add opt-in `CliProfile.batteries_included()` layered configuration with
platform-aware user paths, project and environment files, provenance, and
validated framework settings.
- Add the optional `base-cli[typer]` integration with `attach_typer()`,
`TyperAdapter`, and `get_typer_command()` so Typer command trees can adopt
the same lifecycle without making Typer a core dependency.

### Changed

Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ execution, while `base_cli` provides reusable lifecycle behavior:
- a command context object shared by command code and helper functions
- test helpers built on Click's `CliRunner`

Typer applications can opt into the same lifecycle with the optional
`base-cli[typer]` extra. See [`docs/typer-adapter.md`](docs/typer-adapter.md)
for the migration path; Typer remains optional and is never imported by the
core Click integration.

## Design Goals

CLI tools should be easy to write, but not magical. A command should be
Expand Down
67 changes: 67 additions & 0 deletions docs/typer-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Typer adapter

`base-cli` can wrap an existing [Typer](https://typer.tiangolo.com/) app
without making Typer a core dependency:

```bash
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.

Use `attach_typer()` at the same boundary where a Click app would use
`attach()`:

```python
import typer
import base_cli

cli = typer.Typer()


@cli.command()
def status(verbose: bool = typer.Option(False, "--verbose")) -> None:
ctx = base_cli.get_current_context()
if verbose:
ctx.log.info("checking status")
typer.echo("ready")


command = base_cli.attach_typer(cli, name="example")

if __name__ == "__main__":
raise SystemExit(base_cli.run_app(command))
```

The adapter calls Typer's supported command materializer and returns that same
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.

For an application that needs a custom profile or factories, pass an explicit
`base_cli.App`:

```python
lifecycle = base_cli.App(name="example", profile=my_profile)
command = base_cli.attach_typer(cli, app=lifecycle)
```

Typer's single-command form normally derives a root name from the callback.
Pass `name=` when the lifecycle should use a different program name. A
multi-command Typer app with no explicit name produces an unnamed group, so
`name=` (or a named `App`) is required. The generated command is renamed in
place; callbacks and Typer parameter metadata are not copied or rewritten.

`TyperAdapter` is available when the generated command needs to be retained:

```python
adapter = base_cli.TyperAdapter(cli)
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.
50 changes: 50 additions & 0 deletions examples/typer_consumer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Typer migration example for the optional base-cli adapter.

Install the integration with ``python -m pip install 'base-cli[typer]'``.
Typer continues to own decorators, typed parameters, nested apps, and its
dependency injection; base-cli supplies the invocation lifecycle around the
generated Click tree.
"""

from __future__ import annotations

import typer

import base_cli


cli = typer.Typer(help="A small Typer application with a shared lifecycle.")


@cli.callback()
def callback() -> None:
"""Keep Typer's normal callback and help behavior."""


@cli.command()
def greet(
name: str = typer.Option(..., help="Name to greet."),
access_code: str = typer.Option(..., hidden=True),
) -> None:
"""Greet someone using Typer's typed options."""

context = base_cli.get_current_context()
context.log.info("greeting %s", name)
# The value is available to the command but is redacted from lifecycle
# invocation logs by the same policy as an attached Click application.
del access_code
typer.echo(f"hello {name}")


# A multi-command Typer app without an explicit Typer name produces an unnamed
# Click group. ``name=`` gives base-cli (and the generated usage) a stable
# program name without changing any Typer command declarations.
command = base_cli.attach_typer(
cli,
name="typer-example",
sensitive_parameters={"access_code"},
)


if __name__ == "__main__":
raise SystemExit(base_cli.run_app(command))
4 changes: 4 additions & 0 deletions lib/python/base_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ def _resolve_version() -> str:
WorkspaceRootResolver,
)
from .runtime import RuntimeLayout
from .typer import TyperAdapter, attach_typer, get_typer_command

__all__ = [
"App",
Expand All @@ -114,6 +115,7 @@ def _resolve_version() -> str:
"AttachmentContextFactory",
"AttachmentContract",
"AttachmentServiceFactory",
"TyperAdapter",
"BatteriesIncludedConfigLoader",
"BOOLEAN",
"ApplicationStateT",
Expand Down Expand Up @@ -150,11 +152,13 @@ def _resolve_version() -> str:
"testing",
"argument",
"attach",
"attach_typer",
"command",
"configure_logger",
"delegated_display_command",
"get_command_app",
"get_current_context",
"get_typer_command",
"get_lifecycle_values",
"log_critical",
"log_debug",
Expand Down
177 changes: 177 additions & 0 deletions lib/python/base_cli/typer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""Optional Typer integration for the base-cli lifecycle.

Typer is deliberately not imported when :mod:`base_cli` is imported. The
adapter materializes Typer's own Click command tree and delegates attachment
to the same Click boundary used by :func:`base_cli.attach`. This keeps
Typer's command discovery, parameter conversion, dependency injection, help,
completion, and exception handling intact while adding the base-cli runtime
lifecycle.
"""

from __future__ import annotations

from collections.abc import Iterable
from typing import Any, Callable, TYPE_CHECKING

from .app import App, attach
from .context import Context

if TYPE_CHECKING:
import click
import typer

_TyperApp = typer.Typer
_ClickCommand = click.Command
else:
_TyperApp = Any
_ClickCommand = Any


__all__ = ["TyperAdapter", "attach_typer", "get_typer_command"]


def _require_typer() -> Any:
"""Import Typer on demand and provide an actionable optional-dependency error."""

try:
import typer
except ImportError as exc: # pragma: no cover - exercised without the extra
raise RuntimeError(
"The Typer adapter requires the optional 'typer' dependency. "
"Install it with 'pip install base-cli[typer]'."
) from exc
return typer


def get_typer_command(typer_app: _TyperApp) -> _ClickCommand:
"""Materialize Typer's native Click command tree.

The returned object is the command generated by Typer itself; it is not a
copy and is suitable for passing to :func:`base_cli.attach` or
:meth:`base_cli.App.attach`.
"""

typer = _require_typer()
if not isinstance(typer_app, typer.Typer):
raise TypeError("get_typer_command() requires a typer.Typer instance.")

try:
from typer.main import get_command
except ImportError as exc: # pragma: no cover - defensive for broken Typer installs
raise RuntimeError(
"The installed Typer version does not expose its command adapter. "
"Install a supported version with 'pip install base-cli[typer]'."
) 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):
raise TypeError(
"Typer did not produce a Click command; upgrade to a supported "
"Typer release (currently 0.12 through 0.25)."
)
return command


def _name_command_for_lifecycle(
command: _ClickCommand,
*,
app: App | None,
requested_name: str | None,
) -> None:
"""Give Typer's unnamed multi-command root a base-cli canonical name."""

command_name = getattr(command, "name", None)
target_name = requested_name or (app.name if app is not None else None)
if command_name:
if target_name and command_name != target_name:
# Explicitly selecting a lifecycle name is useful for Typer's
# single-command form, whose generated root name is the callback
# name. Mutating only this generated Click object preserves all
# Typer-owned callbacks and parameters.
command.name = target_name
return
if not target_name:
raise RuntimeError(
"Typer produced an unnamed command group. Pass name='your-cli' "
"or construct a named base_cli.App."
)
command.name = target_name


class TyperAdapter:
"""Attach one Typer application to a base-cli lifecycle.

The adapter caches Typer's generated command so callers can safely use the
returned command for production entry points and tests without creating a
second command tree.
"""

def __init__(self, typer_app: _TyperApp) -> None:
_require_typer()
self.typer_app = typer_app
self.command = get_typer_command(typer_app)

def attach(
self,
*,
app: App | None = None,
context_factory: Callable[[Context[Any, Any, Any]], Any] | None = None,
service_factory: Callable[[Context[Any, Any, Any]], Any] | None = None,
sensitive_parameters: Iterable[str] = (),
name: str | None = None,
**app_kwargs: Any,
) -> _ClickCommand:
"""Attach the cached Typer command and return that same Click object."""

if app is not None and app_kwargs:
unexpected = ", ".join(sorted(app_kwargs))
raise TypeError(f"App constructor arguments cannot be used with app= ({unexpected}).")
if name is not None:
if "name" in app_kwargs:
raise TypeError("Specify the Typer lifecycle name only once.")
if app is None:
app_kwargs["name"] = name
_name_command_for_lifecycle(
self.command,
app=app,
requested_name=name or app_kwargs.get("name"),
)
return attach(
self.command,
app=app,
context_factory=context_factory,
service_factory=service_factory,
sensitive_parameters=sensitive_parameters,
**app_kwargs,
)


def attach_typer(
typer_app: _TyperApp,
*,
app: App | None = None,
context_factory: Callable[[Context[Any, Any, Any]], Any] | None = None,
service_factory: Callable[[Context[Any, Any, Any]], Any] | None = None,
sensitive_parameters: Iterable[str] = (),
name: str | None = None,
**app_kwargs: Any,
) -> _ClickCommand:
"""Attach a Typer application and return its native Click command.

``app_kwargs`` are forwarded to the generic :class:`base_cli.App` when no
explicit ``app`` is supplied. In particular, the generated Typer command
name is used as the canonical lifecycle name by default.
"""

return TyperAdapter(typer_app).attach(
app=app,
context_factory=context_factory,
service_factory=service_factory,
sensitive_parameters=sensitive_parameters,
name=name,
**app_kwargs,
)
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ dev = [
"mypy>=1.17,<2",
"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",
]

[project.urls]
Homepage = "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/basefoundry/base-cli"
Expand Down
Loading