Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ and versions are tracked in the repo-root `VERSION` file.

### Fixed

- Resolve `get_lifecycle_values()` from the active Typer-owned Click context
when no context is passed explicitly.
- Preserve explicit application identities losslessly while using
collision-resistant, path-safe runtime namespace components.
- Give `BatteriesIncludedConfigLoader.cli_name` a documented identity role by
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -643,7 +643,9 @@ subcommand. Disabled and hidden options do not appear in help; renamed options
appear only under their configured declarations.

Normalized values are available as one typed `LifecycleValues` record in the
active Click context's namespaced metadata:
active Click context's namespaced metadata. The context argument is optional;
when omitted, base-cli resolves the active upstream Click or supported Typer
context automatically:

```python
@click.pass_context
Expand Down
44 changes: 37 additions & 7 deletions lib/python/base_cli/_click_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,16 @@ def _vendored_typer_dialect(typer: Any) -> _VendoredClickDialect | None:
except (ImportError, AttributeError):
return None

core = module.core
exceptions = module.exceptions
core = getattr(module, "core", None)
exceptions = getattr(module, "exceptions", None)
echo = getattr(module, "echo", None)
click_exception = getattr(module, "ClickException", None)
abort = getattr(module, "Abort", getattr(exceptions, "Abort", getattr(core, "Abort", None)))
usage_error = getattr(module, "UsageError", getattr(core, "UsageError", None))
if core is None or exceptions is None or not callable(echo):
return None
if not isinstance(abort, type) or not isinstance(usage_error, type) or not isinstance(click_exception, type):
return None

def option(param_decls: list[str], **attrs: Any) -> Any:
return TyperOption(param_decls=list(param_decls), **attrs)
Expand All @@ -108,11 +116,11 @@ def option(param_decls: list[str], **attrs: Any) -> Any:
Command=module.Command,
Option=option,
Path=TyperPath,
version_option=_vendor_version_option_factory(TyperOption, module.echo),
version_option=_vendor_version_option_factory(TyperOption, echo),
exceptions=exceptions,
Abort=getattr(module, "Abort", getattr(exceptions, "Abort", core.Abort)),
UsageError=getattr(module, "UsageError", core.UsageError),
ClickException=module.ClickException,
Abort=abort,
UsageError=usage_error,
ClickException=click_exception,
)


Expand All @@ -125,6 +133,21 @@ def dialect_for_typer(typer: Any) -> Any:
return dialect if dialect is not None else click


def current_context_candidates(typer: Any, click: Any) -> list[Any]:
"""Return active contexts from Typer's dialect followed by public Click."""

dialect = dialect_for_typer(typer)
candidates: list[Any] = []
if dialect is not click:
get_context = getattr(dialect, "get_current_context", None)
if get_context is None:
get_context = getattr(getattr(dialect, "globals", None), "get_current_context", None)
if callable(get_context):
candidates.append(get_context(silent=True))
candidates.append(click.get_current_context(silent=True))
return candidates


def exit_exception_type(click: Any) -> type[BaseException]:
"""Return the owning dialect's exit exception across Click variants.

Expand Down Expand Up @@ -189,4 +212,11 @@ def is_command(command: Any) -> bool:
return dialect is not click and isinstance(command, dialect.Command)


__all__ = ["dialect_for_command", "dialect_for_typer", "exit_exception_type", "is_command", "mark_command_dialect"]
__all__ = [
"current_context_candidates",
"dialect_for_command",
"dialect_for_typer",
"exit_exception_type",
"is_command",
"mark_command_dialect",
]
19 changes: 18 additions & 1 deletion lib/python/base_cli/lifecycle_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,24 @@ def get_lifecycle_values(click_context: Any | None = None) -> LifecycleValues:
import click
except ImportError as exc:
raise RuntimeError("Click is required to inspect lifecycle option values.") from exc
click_context = click.get_current_context(silent=True)
candidates: list[Any] = []
try:
import typer
except ImportError:
pass
else:
from ._click_compat import current_context_candidates

candidates.extend(current_context_candidates(typer, click))
click_context = next(
(
candidate
for candidate in candidates
if candidate is not None
and isinstance(getattr(candidate, "meta", {}).get(LIFECYCLE_META_KEY), LifecycleValues)
),
next((candidate for candidate in candidates if candidate is not None), None),
)
if click_context is None:
raise RuntimeError("Lifecycle option values are not available outside a Click invocation.")
value = getattr(click_context, "meta", {}).get(LIFECYCLE_META_KEY)
Expand Down
4 changes: 4 additions & 0 deletions tests/test_platform_edge_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ def test_vendored_dialect_requires_a_click_module(self) -> None:
self.assertIsNone(click_compat._vendored_typer_dialect(types.SimpleNamespace())) # pylint: disable=protected-access
self.assertIs(click_compat.dialect_for_typer(types.SimpleNamespace()), __import__("click"))

def test_vendored_dialect_rejects_incomplete_click_module(self) -> None:
incomplete = types.SimpleNamespace(Command=object, core=None, exceptions=None)
self.assertIsNone(click_compat._vendored_typer_dialect(types.SimpleNamespace(_click=incomplete))) # pylint: disable=protected-access

def test_marking_an_immutable_command_is_best_effort(self) -> None:
class Immutable:
__slots__ = ()
Expand Down
24 changes: 24 additions & 0 deletions tests/test_typer_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,30 @@ def greet(
self.assertIsInstance(observed["run_id"], str)
self.assertEqual(observed["command"], "typer-cli")

def test_get_lifecycle_values_resolves_the_active_typer_context(self) -> None:
admin = self.typer.Typer()
cli = self.typer.Typer()
cli.add_typer(admin, name="admin")
observed: list[base_cli.LifecycleValues] = []

@admin.command()
def status() -> None:
observed.append(base_cli.get_lifecycle_values())

command = base_cli.attach_typer(
cli,
name="typer-values",
log_to_file=False,
lifecycle_options=base_cli.LifecycleOptions(
debug=base_cli.LifecycleOption("--debug/--no-debug"),
),
)
with tempfile.TemporaryDirectory() as home:
enabled = base_cli.testing.invoke(command, ["--debug", "admin", "status"], home=Path(home))

self.assertEqual(enabled.exit_code, 0, enabled.output)
self.assertEqual([value.debug for value in observed], [True])

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")
Expand Down
Loading