From 3969cdb63636356d8ff9ec48a6fb469c3d9273e7 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:35:21 +0530 Subject: [PATCH 1/3] fix: resolve lifecycle values from Typer context --- CHANGELOG.md | 2 ++ README.md | 4 +++- docs/api-reference.md | 2 +- lib/python/base_cli/lifecycle_options.py | 26 +++++++++++++++++++++++- tests/test_typer_adapter.py | 24 ++++++++++++++++++++++ 5 files changed, 55 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec20c2e..c4069f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 23dc8dd..436ba52 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/api-reference.md b/docs/api-reference.md index fc6abc8..4ab6f8d 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1128,7 +1128,7 @@ base_cli.get_typer_command(...) **Kind:** function **Signature:** `get_lifecycle_values(click_context: 'Any | None' = None) -> 'LifecycleValues'` -**Behavior:** Return normalized lifecycle values stored on an active Click context. +**Behavior:** Return normalized lifecycle values stored on an active Click context. When omitted, the context is resolved from upstream Click or the active supported Typer Click dialect. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. diff --git a/lib/python/base_cli/lifecycle_options.py b/lib/python/base_cli/lifecycle_options.py index 41533bf..bd005b3 100644 --- a/lib/python/base_cli/lifecycle_options.py +++ b/lib/python/base_cli/lifecycle_options.py @@ -186,7 +186,31 @@ 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 dialect_for_typer + + dialect = dialect_for_typer(typer) + 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)) + 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) diff --git a/tests/test_typer_adapter.py b/tests/test_typer_adapter.py index d78fc84..e4b9fc6 100644 --- a/tests/test_typer_adapter.py +++ b/tests/test_typer_adapter.py @@ -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") From a5cf52c24f88f3a447b629310586dd0a8698e166 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:42:47 +0530 Subject: [PATCH 2/3] docs: regenerate Typer lifecycle API reference --- docs/api-reference.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 4ab6f8d..fc6abc8 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1128,7 +1128,7 @@ base_cli.get_typer_command(...) **Kind:** function **Signature:** `get_lifecycle_values(click_context: 'Any | None' = None) -> 'LifecycleValues'` -**Behavior:** Return normalized lifecycle values stored on an active Click context. When omitted, the context is resolved from upstream Click or the active supported Typer Click dialect. +**Behavior:** Return normalized lifecycle values stored on an active Click context. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. From 999a5462520b3f703dd9158b63576e51a04e5523 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:21:14 +0530 Subject: [PATCH 3/3] fix: centralize Typer context compatibility --- lib/python/base_cli/_click_compat.py | 44 ++++++++++++++++++++---- lib/python/base_cli/lifecycle_options.py | 13 ++----- tests/test_platform_edge_paths.py | 4 +++ 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/lib/python/base_cli/_click_compat.py b/lib/python/base_cli/_click_compat.py index 96619a3..8faab4c 100644 --- a/lib/python/base_cli/_click_compat.py +++ b/lib/python/base_cli/_click_compat.py @@ -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) @@ -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, ) @@ -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. @@ -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", +] diff --git a/lib/python/base_cli/lifecycle_options.py b/lib/python/base_cli/lifecycle_options.py index bd005b3..4b7f9d2 100644 --- a/lib/python/base_cli/lifecycle_options.py +++ b/lib/python/base_cli/lifecycle_options.py @@ -192,16 +192,9 @@ def get_lifecycle_values(click_context: Any | None = None) -> LifecycleValues: except ImportError: pass else: - from ._click_compat import dialect_for_typer - - dialect = dialect_for_typer(typer) - 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)) + from ._click_compat import current_context_candidates + + candidates.extend(current_context_candidates(typer, click)) click_context = next( ( candidate diff --git a/tests/test_platform_edge_paths.py b/tests/test_platform_edge_paths.py index e83fe3b..d8cc326 100644 --- a/tests/test_platform_edge_paths.py +++ b/tests/test_platform_edge_paths.py @@ -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__ = ()