diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cff6eaf..faff0f8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 @@ -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__)" ' diff --git a/CHANGELOG.md b/CHANGELOG.md index cb11aae..e445894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index d1da0c8..e4d4f17 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/typer-adapter.md b/docs/typer-adapter.md new file mode 100644 index 0000000..e3920fb --- /dev/null +++ b/docs/typer-adapter.md @@ -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. diff --git a/examples/typer_consumer.py b/examples/typer_consumer.py new file mode 100644 index 0000000..11282dc --- /dev/null +++ b/examples/typer_consumer.py @@ -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)) diff --git a/lib/python/base_cli/__init__.py b/lib/python/base_cli/__init__.py index 1cd183c..a5ddd4a 100644 --- a/lib/python/base_cli/__init__.py +++ b/lib/python/base_cli/__init__.py @@ -106,6 +106,7 @@ def _resolve_version() -> str: WorkspaceRootResolver, ) from .runtime import RuntimeLayout +from .typer import TyperAdapter, attach_typer, get_typer_command __all__ = [ "App", @@ -114,6 +115,7 @@ def _resolve_version() -> str: "AttachmentContextFactory", "AttachmentContract", "AttachmentServiceFactory", + "TyperAdapter", "BatteriesIncludedConfigLoader", "BOOLEAN", "ApplicationStateT", @@ -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", diff --git a/lib/python/base_cli/typer.py b/lib/python/base_cli/typer.py new file mode 100644 index 0000000..52dc885 --- /dev/null +++ b/lib/python/base_cli/typer.py @@ -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, + ) diff --git a/pyproject.toml b/pyproject.toml index ddcc57f..30a31de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_public_api.py b/tests/test_public_api.py index ef8d904..bd9176d 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -6,7 +6,7 @@ from unittest import mock import base_cli -from base_cli import attachment, command_filters, command_protocol, config, history, lifecycle_options +from base_cli import attachment, command_filters, command_protocol, config, history, lifecycle_options, typer class PublicApiTests(unittest.TestCase): @@ -38,6 +38,9 @@ def test_facade_exports_supported_modules_functions_and_types(self) -> None: "AttachmentAdapter", "AttachmentContract", "BatteriesIncludedConfigLoader", + "TyperAdapter", + "attach_typer", + "get_typer_command", "ConfigSnapshot", "RuntimeLayout", "attach", @@ -62,6 +65,7 @@ def test_facade_exports_supported_modules_functions_and_types(self) -> None: self.assertFalse(hasattr(base_cli, "UserConfig")) self.assertIs(base_cli.command_filters, command_filters) self.assertIs(base_cli.command_protocol, command_protocol) + self.assertIs(base_cli.typer, typer) self.assertTrue(issubclass(base_cli.ConfigurationError, ValueError)) def test_module_all_surfaces_are_explicit(self) -> None: @@ -119,6 +123,10 @@ def test_module_all_surfaces_are_explicit(self) -> None: self.assertIn("write_primary_record", history.__all__) self.assertNotIn("lock_history_file", history.__all__) self.assertNotIn("write_all", history.__all__) + self.assertEqual( + set(typer.__all__), + {"TyperAdapter", "attach_typer", "get_typer_command"}, + ) def test_entry_points_have_docstrings(self) -> None: self.assertTrue(base_cli.App.__doc__) diff --git a/tests/test_typer_adapter.py b/tests/test_typer_adapter.py new file mode 100644 index 0000000..75fb464 --- /dev/null +++ b/tests/test_typer_adapter.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import importlib.util +import re +import tempfile +import unittest +from pathlib import Path + +import base_cli + + +@unittest.skipUnless(importlib.util.find_spec("typer"), "Typer is not installed") +class TyperAdapterTests(unittest.TestCase): + def setUp(self) -> None: + import typer + + self.typer = typer + + def test_adapter_preserves_typer_tree_typed_parameters_and_context(self) -> None: + cli = self.typer.Typer(help="Typer root") + observed: dict[str, object] = {} + + @cli.command() + def greet( + name: str, + count: int = self.typer.Option(1, min=1), + access_code: str = self.typer.Option(...), + ) -> None: + context = base_cli.get_current_context() + observed["run_id"] = context.run_id + observed["command"] = context.cli_name + for _ in range(count): + self.typer.echo(f"hello {name}") + del access_code + + command = base_cli.attach_typer( + cli, + name="typer-cli", + log_to_file=False, + sensitive_parameters={"access_code"}, + ) + + self.assertIsInstance(command, __import__("click").Command) + self.assertEqual(command.name, "typer-cli") + with tempfile.TemporaryDirectory() as home: + result = base_cli.testing.invoke( + command, + ["--quiet", "Ada", "--count", "2", "--access-code", "secret"], + home=Path(home), + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(result.stdout.count("hello Ada"), 2) + self.assertIsInstance(observed["run_id"], str) + self.assertEqual(observed["command"], "typer-cli") + + def test_nested_apps_help_and_click_exception_remain_native(self) -> None: + admin = self.typer.Typer(help="Administrative commands") + cli = self.typer.Typer(help="Root help") + cli.add_typer(admin, name="admin") + + @admin.command() + def status() -> None: + self.typer.echo("ready") + + @admin.command() + def fail() -> None: + raise self.typer.BadParameter("invalid state") + + command = base_cli.attach_typer(cli, name="nested-cli", log_to_file=False) + with tempfile.TemporaryDirectory() as home: + help_result = base_cli.testing.invoke( + command, + ["--help"], + home=Path(home), + ) + status_result = base_cli.testing.invoke( + command, + ["--quiet", "admin", "status"], + home=Path(home), + ) + failure_result = base_cli.testing.invoke( + command, + ["admin", "fail"], + home=Path(home), + ) + + self.assertEqual(help_result.exit_code, 0, help_result.output) + self.assertIn("Root help", help_result.stdout) + plain_help = re.sub(r"\x1b\[[0-9;]*m", "", help_result.stdout) + self.assertIn("--debug", plain_help) + self.assertEqual(status_result.exit_code, 0, status_result.output) + self.assertIn("ready", status_result.stdout) + self.assertNotEqual(failure_result.exit_code, 0) + self.assertIn("invalid state", failure_result.stderr) + + def test_adapter_class_caches_generated_command(self) -> None: + cli = self.typer.Typer() + + @cli.command() + def status() -> None: + self.typer.echo("ready") + + adapter = base_cli.TyperAdapter(cli) + command = adapter.attach(name="cached-cli", log_to_file=False) + self.assertIs(command, adapter.command) + self.assertEqual(command.name, "cached-cli") + + def test_unnamed_multi_command_requires_a_lifecycle_name(self) -> None: + cli = self.typer.Typer() + + @cli.command() + def first() -> None: + pass + + @cli.command() + def second() -> None: + pass + + with self.assertRaisesRegex(RuntimeError, "unnamed command group"): + base_cli.attach_typer(cli, log_to_file=False) + + +if __name__ == "__main__": + unittest.main()