diff --git a/CHANGELOG.md b/CHANGELOG.md index d6043d0..8672954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and versions are tracked in the repo-root `VERSION` file. ### Added +- Add immutable `LifecycleOptions` and `LifecycleOption` policies for enabling, + disabling, renaming, and configuring each standard option independently, with + normalized `LifecycleValues` stored in namespaced Click metadata without + replacing application-owned `Context.obj` state. - Add `App.attach()` and `base_cli.attach()` for applying one lifecycle to existing nested, aliased, chained, and lazy Click command trees while preserving their native callbacks, contexts, and result values. diff --git a/README.md b/README.md index 1942dde..360ff3a 100644 --- a/README.md +++ b/README.md @@ -378,8 +378,8 @@ Parameters whose names contain `token`, `password`, `secret`, `api-key` receive already-redacted argv, so raw secret-bearing argv never crosses the framework's persistence boundary. -Use `dry_run=True` when a nonstandard option should drive `ctx.dry_run` and -the lifecycle's default durable-write suppression: +For native `App` commands, use `dry_run=True` when a nonstandard option should +drive `ctx.dry_run` and the lifecycle's default durable-write suppression: ```python @base_cli.option("--preview", is_flag=True, dry_run=True) @@ -388,11 +388,11 @@ def main(ctx: base_cli.Context, preview: bool) -> None: ctx.log.info("previewing changes") ``` -The conventional `dry_run` parameter is recognized automatically, so commands -using `@base_cli.option("--dry-run", is_flag=True)` do not need the marker. -Only one option on a command may be marked `dry_run=True`; duplicate dry-run -markers fail during command registration so authors do not accidentally ship an -option that is ignored by `ctx.dry_run`. +The conventional `dry_run` parameter is still recognized automatically, so +native commands using `@base_cli.option("--dry-run", is_flag=True)` do not need +the marker. Only one option on a command may be marked `dry_run=True`; duplicate +dry-run markers fail during command registration so authors do not accidentally +ship an option that is ignored by `ctx.dry_run`. ## Standard Options @@ -406,8 +406,126 @@ Every `base_cli.App` command gets these options: - `--log-file `: write the persistent log to a specific file. - `--version`: shown when the `App` was created with a version. -The command receives only its own application-specific options. Standard options -are consumed before the command function is called. +`LifecycleOptions()` preserves this default set. Its `debug`, `quiet`, +`environment`, `config`, `keep_temp`, `log_file`, and `version` fields are +enabled by default; `dry_run` is opt-in. Set one field to `None` to disable it, +or replace it with a `LifecycleOption` to rename and configure it independently: + +```python +lifecycle_options = base_cli.LifecycleOptions( + config=None, + quiet=base_cli.LifecycleOption( + "--silent", + "-s", + help="Suppress routine status messages.", + ), + environment=base_cli.LifecycleOption( + "--stage", + help="Select the deployment stage.", + metavar="NAME", + envvar="WORKSPACE_STAGE", + show_envvar=True, + default="dev", + show_default=True, + ), +) + +app = base_cli.App( + name="workspace-tools", + version="1.2.3", + lifecycle_options=lifecycle_options, +) +``` + +`LifecycleOption` accepts Click declarations followed by the keyword-only +`name`, `help`, `metavar`, `envvar`, `show_envvar`, `show_default`, `hidden`, +and `default` presentation and value-source settings. When `name` is omitted, +Click derives the public destination from the visible declaration: the +`--stage` option above therefore uses `stage` in a Click `default_map` and +`WORKSPACE_STAGE` as its explicit environment variable. Use `name` only when a +different stable Click destination is intentional. Option shapes remain owned +by the lifecycle: flags stay scalar flags, paths retain their validation, and +declaration or destination collisions fail when commands are materialized or +attached. + +Click value sources use this precedence, from strongest to weakest: + +1. command-line value; +2. explicit `envvar` or Click `auto_envvar_prefix` value; +3. Click `default_map` value; +4. configured option default. + +For native command groups, a stronger source wins across root and leaf +placements; when both values have the same source, the leaf value wins. An +unspecified leaf value never erases a root value. Thus an explicit root +`--stage prod` beats a leaf `default_map`, while a leaf command-line value beats +a root command-line value. + +The default placement remains compatibility-oriented and deterministic. A +native single-command `App` installs lifecycle options on that command. A +native subcommand `App` installs them on both the root and every leaf, so both +`workspace-tools --debug status` and `workspace-tools status --debug` work and +the corresponding help page shows the option. `--version` remains root-only +for groups. An attached Click tree installs lifecycle options only on its root, +without enumerating lazy descendants, so they must precede the first +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: + +```python +@click.pass_context +def inspect(click_ctx: click.Context) -> None: + values = base_cli.get_lifecycle_values(click_ctx) + assert isinstance(values, base_cli.LifecycleValues) + assert values is click_ctx.meta[base_cli.LIFECYCLE_META_KEY] + print(values.environment, values.debug, values.dry_run) +``` + +The metadata record, rather than `click.Context.obj`, carries values between a +native group and its leaf. Base-cli neither replaces nor copies `obj`; typed +objects, dictionaries, and `None` retain their existing Click semantics. The +command callback receives only its application-specific parameters, not the +lifecycle fields. + +Attached applications also honor public Click source names. For example, +`default_map={"stage": "test"}` supplies the renamed `--stage` option above, +and a runtime `auto_envvar_prefix="WORKSPACE"` reads `WORKSPACE_STAGE`. Callers +never need private `_base_cli_*` destination or environment names. + +Dry-run attachment is explicit because existing Click applications may already +own that spelling. Opt it in through the same configuration: + +```python +lifecycle_options = base_cli.LifecycleOptions( + dry_run=base_cli.LifecycleOption( + "--dry-run", + help="Run without default durable writes.", + ), +) +lifecycle = base_cli.App( + name=cli.name, + lifecycle_options=lifecycle_options, +) +lifecycle.attach(cli) +``` + +If the attached root already exposes a compatible `--dry-run` option, base-cli +reuses it while preserving its callback and destination. Otherwise the option +is added at the root and consumed by the lifecycle. The default +`LifecycleOptions()` does not add attached dry-run behavior; the native +conventional-name and `dry_run=True` decorator contracts described above remain +supported. This preservation rule applies to every adopted vendor option: omit +`name=` to use its existing Click destination, or choose a distinct declaration +when an explicitly configured destination must be enforced. A conflicting +explicit destination fails during attachment instead of being silently ignored. +Every configured alias must already be present on an adopted option; base-cli +never mutates the vendor declaration list. The vendor option also continues to +own its callback, type, default, environment-variable settings, help text, +metavar, and visibility. Those `LifecycleOption` settings configure options +created by base-cli; choose a distinct primary declaration when base-cli should +own those semantics. ## Exit Codes diff --git a/lib/python/base_cli/__init__.py b/lib/python/base_cli/__init__.py index 40e39ac..6e6be43 100644 --- a/lib/python/base_cli/__init__.py +++ b/lib/python/base_cli/__init__.py @@ -58,6 +58,13 @@ def _resolve_version() -> str: from .exit_codes import ExitCode from .inspection import inspection_envelope, render_inspection_json from .logging import configure_logger, log_critical, log_debug, log_error, log_info, log_warning +from .lifecycle_options import ( + LIFECYCLE_META_KEY, + LifecycleOption, + LifecycleOptions, + LifecycleValues, + get_lifecycle_values, +) from .output import ( OutputFormatError, PUBLIC_OUTPUT_FORMATS, @@ -80,6 +87,10 @@ def _resolve_version() -> str: "Context", "ExitCode", "FieldSpec", + "LIFECYCLE_META_KEY", + "LifecycleOption", + "LifecycleOptions", + "LifecycleValues", "NULLABLE_STRING", "STRING", "command_filters", @@ -98,6 +109,7 @@ def _resolve_version() -> str: "delegated_display_command", "get_command_app", "get_current_context", + "get_lifecycle_values", "log_critical", "log_debug", "log_error", diff --git a/lib/python/base_cli/app.py b/lib/python/base_cli/app.py index 9158717..74eeec6 100644 --- a/lib/python/base_cli/app.py +++ b/lib/python/base_cli/app.py @@ -34,16 +34,48 @@ from .exit_codes import ExitCode from .history import utc_now from .logging import configure_logger, log_invocation +from .lifecycle_options import ( + LIFECYCLE_META_KEY, + LifecycleOption, + LifecycleOptions, + LifecycleValues, +) from .paths import ( current_working_dir, normalize_cli_name, ) from .profile import CliProfile -from .redaction import REDACTED, RedactionPlan, compile_redaction_plan, parameter_name_from_decls, redact_argv +from .redaction import ( + REDACTED, + RedactionPlan, + compile_redaction_plan, + option_aliases_from_decls, + parameter_name_from_decls, + redact_argv, +) _STANDARD_OPTION_KEYS = ("debug", "quiet", "environment", "config", "keep_temp", "log_file") -_GROUP_STANDARD_OPTIONS_KEY = "base_cli_standard_options" -_ATTACHED_STANDARD_OPTIONS_KEY = object() +_FLAG_LIFECYCLE_OPTION_KEYS = frozenset({"debug", "quiet", "keep_temp", "dry_run"}) +_NATIVE_LIFECYCLE_OPTION_ORDER = ( + "quiet", + "debug", + "environment", + "config", + "keep_temp", + "log_file", + "dry_run", +) +_ATTACHED_LIFECYCLE_OPTION_ORDER = ( + "log_file", + "keep_temp", + "config", + "environment", + "debug", + "quiet", + "dry_run", +) +_LIFECYCLE_CAPTURE_META_KEY = object() +_LIFECYCLE_RESOLUTION_META_KEY = object() DISPLAY_COMMAND_ENV = "BASE_CLI_DISPLAY_COMMAND" _INVOCATION_ARGV: ContextVar[list[str] | None] = ContextVar("base_cli_invocation_argv", default=None) _INVOCATION_MAIN_BYPASS: ContextVar[Any | None] = ContextVar( @@ -59,6 +91,7 @@ _CLICK_ORIGINAL_RESOLVE_ATTRIBUTE = "__base_cli_original_resolve__" _CLICK_ORIGINAL_MAIN_ATTRIBUTE = "__base_cli_original_main__" _CLICK_APP_OWNER_ATTRIBUTE = "__base_cli_app_owner__" +_CLICK_LIFECYCLE_BINDINGS_ATTRIBUTE = "__base_cli_lifecycle_bindings__" _CLICK_ATTACHMENT_LOCK = RLock() _REGISTRATION_OPEN = "open" _REGISTRATION_MATERIALIZING = "materializing" @@ -73,6 +106,7 @@ class _InvocationState: log_file: Path | None = None debug: bool = False quiet: bool = False + debug_option: str | None = "--debug" options_parsed: bool = False attached_completion: bool = False @@ -85,6 +119,26 @@ class _SubcommandRegistration: name: str +@dataclass(frozen=True) +class _LifecycleBinding: + key: str + parameter_name: str + adopted: bool + + +@dataclass(frozen=True) +class _RawLifecycleValue: + value: Any + source: Any + depth: int + + +@dataclass(frozen=True) +class _LifecycleResolution: + values: LifecycleValues + raw: dict[str, _RawLifecycleValue] + + @dataclass(frozen=True) class _ClickAttachment: app: Any @@ -92,7 +146,8 @@ class _ClickAttachment: context_factory: Callable[[Context], Any] | None service_factory: Callable[[Context], Any] | None sensitive_parameters: frozenset[str] - standard_bindings: dict[str, str] + lifecycle_options: LifecycleOptions + standard_bindings: dict[str, _LifecycleBinding] class _AttachedInvocation: @@ -395,6 +450,7 @@ def __init__( log_to_file: bool = True, max_log_files: int | None = None, profile: CliProfile | None = None, + lifecycle_options: LifecycleOptions | None = None, ) -> None: if max_log_files is not None and max_log_files < 1: raise ValueError("max_log_files must be greater than 0 when set.") @@ -409,6 +465,12 @@ def __init__( # conventions. Consumers with product-specific policies should pass an # explicit profile. self.profile = profile or CliProfile.generic() + if lifecycle_options is not None and not isinstance( + lifecycle_options, + LifecycleOptions, + ): + raise TypeError("lifecycle_options must be a LifecycleOptions instance or None.") + self._lifecycle_options = lifecycle_options or LifecycleOptions() self._click_command = None self._redaction_plan: RedactionPlan | None = None self._command_func: Callable[..., Any] | None = None @@ -422,6 +484,18 @@ def __init__( def name(self) -> str: return self._name + @property + def lifecycle_options(self) -> LifecycleOptions: + return self._lifecycle_options + + @lifecycle_options.setter + def lifecycle_options(self, value: LifecycleOptions) -> None: + if not isinstance(value, LifecycleOptions): + raise TypeError("lifecycle_options must be a LifecycleOptions instance.") + with self._registration_lock: + self._ensure_registration_open() + self._lifecycle_options = value + @name.setter def name(self, value: str) -> None: normalized = normalize_cli_name(value) @@ -558,6 +632,7 @@ def attach( and existing.context_factory is context_factory and existing.service_factory is service_factory and existing.sensitive_parameters == normalized_sensitive_parameters + and existing.lifecycle_options == self.lifecycle_options and self._attached_command is command and self._click_command is command and self._registration_state == _REGISTRATION_FROZEN @@ -614,6 +689,7 @@ def attach( standard_bindings = _add_attached_standard_options( click, command, + lifecycle_options=self.lifecycle_options, version=self.version, added_parameters=added_parameters, ) @@ -628,6 +704,7 @@ def attach( context_factory=context_factory, service_factory=service_factory, sensitive_parameters=normalized_sensitive_parameters, + lifecycle_options=self.lifecycle_options, standard_bindings=standard_bindings, ) _instrument_attached_click_command(click, command) @@ -711,7 +788,7 @@ def _build_click_command(self) -> Any: click = _require_click() if self._command_func is not None: - wrapper = self._build_command_wrapper(click, self._command_func, include_version=True) + wrapper = self._build_command_wrapper(click, self._command_func) command_kwargs = dict(self._command_kwargs) if self.help is not None: command_kwargs.setdefault("help", self.help) @@ -722,14 +799,26 @@ def _build_click_command(self) -> Any: command_kwargs, )(wrapper) _require_materialized_command_name(command, self.name, self.name) + _install_native_lifecycle_options( + click, + command, + self.lifecycle_options, + version=self.version, + ) setattr(command, _CLICK_APP_OWNER_ATTRIBUTE, self) return command - group_wrapper = _decorate_standard_options(click, _build_group_wrapper(click), self.version) + group_wrapper = _build_group_wrapper(click) group = click.group(name=self.name, help=self.help)(group_wrapper) + _install_native_lifecycle_options( + click, + group, + self.lifecycle_options, + version=self.version, + ) setattr(group, _CLICK_APP_OWNER_ATTRIBUTE, self) for registration in self._subcommands: - wrapper = self._build_command_wrapper(click, registration.func, include_version=False) + wrapper = self._build_command_wrapper(click, registration.func) command = _click_command_decorator( click, registration.name, @@ -737,6 +826,12 @@ def _build_click_command(self) -> Any: registration.kwargs, )(wrapper) _require_materialized_command_name(command, registration.name, self.name) + _install_native_lifecycle_options( + click, + command, + self.lifecycle_options, + version=None, + ) setattr(command, _CLICK_APP_OWNER_ATTRIBUTE, self) # Supplying the canonical name explicitly also prevents a custom # Command implementation from changing the group key between the @@ -751,9 +846,30 @@ def _build_command_wrapper( self, click: Any, func: Callable[..., Any], - include_version: bool, ) -> Callable[..., Any]: - dry_run_parameter = getattr(func, "__base_cli_dry_run_parameter__", "dry_run") + explicit_dry_run_parameter = getattr( + func, + "__base_cli_dry_run_parameter__", + None, + ) + conventional_dry_run_parameter = any( + parameter_name_from_decls(param_decls) == "dry_run" + for _kind, param_decls, _attrs, *_metadata in getattr( + func, + "__base_cli_param_specs__", + (), + ) + ) + if self.lifecycle_options.dry_run is not None and ( + explicit_dry_run_parameter is not None + or conventional_dry_run_parameter + ): + conflicting_parameter = explicit_dry_run_parameter or "dry_run" + raise RuntimeError( + f"{func.__name__} designates '{conflicting_parameter}' as dry-run, " + "but LifecycleOptions.dry_run is also enabled. Use only one dry-run source." + ) + dry_run_parameter = explicit_dry_run_parameter or "dry_run" @functools.wraps(func) def wrapper(**kwargs: Any): @@ -762,11 +878,30 @@ def wrapper(**kwargs: Any): f"base_cli command '{self.name}' cannot run inside an attached " "Click tree because that would create a second lifecycle." ) - standard = _merge_standard_options( - _group_standard_options(click), - _pop_standard_options(kwargs), + click_context = click.get_current_context() + bindings = getattr( + click_context.command, + _CLICK_LIFECYCLE_BINDINGS_ATTRIBUTE, + {}, + ) + extra_values: dict[str, _RawLifecycleValue] = {} + if ( + self.lifecycle_options.dry_run is None + and dry_run_parameter in kwargs + ): + extra_values["dry_run"] = _RawLifecycleValue( + value=kwargs.get(dry_run_parameter), + source=click_context.get_parameter_source(dry_run_parameter), + depth=_context_depth(click_context), + ) + resolution = _resolve_lifecycle_values( + click, + click_context, + bindings, + extra_values=extra_values, ) - _validate_standard_options(click, standard) + standard = _standard_options_from_values(resolution.values) + _validate_standard_options(click, standard, self.lifecycle_options) _capture_standard_options(standard, self) started_at = utc_now() started_monotonic_ns = time.monotonic_ns() @@ -782,7 +917,7 @@ def wrapper(**kwargs: Any): try: context = self._create_context( standard, - dry_run=bool(kwargs.get(dry_run_parameter)), + dry_run=resolution.values.dry_run, ) except ConfigurationError as exc: raise click.UsageError(str(exc)) from exc @@ -865,7 +1000,6 @@ def wrapper(**kwargs: Any): click_parameters = getattr(wrapper, "__click_params__", ()) if click_parameters: click_parameters[-1]._base_cli_sensitive = True - wrapper = _decorate_standard_options(click, wrapper, self.version if include_version else None) return wrapper def _create_context(self, standard: dict[str, Any], dry_run: bool = False) -> Context: @@ -1020,12 +1154,13 @@ def __init__( click: Any, attachment: _ClickAttachment, click_context: Any, - standard: dict[str, Any], + lifecycle_values: LifecycleValues, ) -> None: self.click = click self.attachment = attachment self.click_context = click_context - self.standard = standard + self.lifecycle_values = lifecycle_values + self.standard = _standard_options_from_values(lifecycle_values) self.started_at = utc_now() self.started_monotonic_ns = time.monotonic_ns() self.context: Context | None = None @@ -1042,7 +1177,7 @@ def __enter__(self) -> _AttachedLifecycleResource: try: context = self.attachment.app._create_context( # pylint: disable=protected-access self.standard, - dry_run=False, + dry_run=self.lifecycle_values.dry_run, ) except ConfigurationError as exc: raise self.click.UsageError(str(exc)) from exc @@ -1222,107 +1357,617 @@ def _normalize_sensitive_parameters(values: Iterable[str]) -> frozenset[str]: return normalized -def _add_attached_standard_options( +def _lifecycle_option_attrs( + click: Any, + key: str, + option: LifecycleOption, +) -> dict[str, Any]: + attrs: dict[str, Any] = {} + if key in _FLAG_LIFECYCLE_OPTION_KEYS: + attrs.update(is_flag=True, default=option.default) + elif key == "config": + attrs.update(type=_explicit_config_path_type(click), default=option.default) + elif key == "log_file": + attrs.update( + type=click.Path(dir_okay=False, path_type=Path), + default=option.default, + ) + else: + attrs["default"] = option.default + if option.help is not None: + attrs["help"] = option.help + if option.metavar is not None: + attrs["metavar"] = option.metavar + if option.envvar is not None: + attrs["envvar"] = option.envvar + if option.show_envvar: + attrs["show_envvar"] = True + if option.show_default is not None: + attrs["show_default"] = option.show_default + if option.hidden: + attrs["hidden"] = True + return attrs + + +def _lifecycle_param_decls(option: LifecycleOption) -> list[str]: + declarations = list(option.param_decls) + if option.name is not None: + declarations.append(option.name) + return declarations + + +def _context_depth(click_context: Any) -> int: + depth = 0 + current = getattr(click_context, "parent", None) + while current is not None: + depth += 1 + current = getattr(current, "parent", None) + return depth + + +def _capture_lifecycle_option( + click_context: Any, + parameter: Any, + value: Any, + *, + key: str, +) -> Any: + source = click_context.get_parameter_source(parameter.name) + captures = click_context.meta.setdefault(_LIFECYCLE_CAPTURE_META_KEY, {}) + context_values = captures.setdefault(id(click_context), {}) + context_values[key] = _RawLifecycleValue( + value=value, + source=source, + depth=_context_depth(click_context), + ) + return value + + +def _make_lifecycle_value_option( + click: Any, + key: str, + option: LifecycleOption, +) -> Any: + def capture(click_context: Any, parameter: Any, value: Any) -> Any: + return _capture_lifecycle_option( + click_context, + parameter, + value, + key=key, + ) + + expected_flag = key in _FLAG_LIFECYCLE_OPTION_KEYS + has_secondary_declaration = any( + (";" if declaration.startswith("/") else "/") in declaration + for declaration in option.param_decls + ) + if not expected_flag and has_secondary_declaration: + raise RuntimeError( + f"LifecycleOptions.{key} must accept one scalar value; its configured " + "declarations change the lifecycle-owned Click option shape." + ) + attrs = _lifecycle_option_attrs(click, key, option) + attrs.update(callback=capture, expose_value=False) + parameter = click.Option(_lifecycle_param_decls(option), **attrs) + if not isinstance(getattr(parameter, "name", None), str) or not parameter.name: + raise RuntimeError( + f"LifecycleOptions.{key} does not produce a stable Click destination." + ) + if bool(getattr(parameter, "is_flag", False)) != expected_flag: + expected_shape = "a scalar flag" if expected_flag else "one scalar value" + raise RuntimeError( + f"LifecycleOptions.{key} must accept {expected_shape}; its configured " + "declarations change the lifecycle-owned Click option shape." + ) + return parameter + + +def _make_lifecycle_version_option( + click: Any, + option: LifecycleOption, + version: str, +) -> Any: + def version_parameter_source() -> None: + return None + + attrs: dict[str, Any] = {} + if option.help is not None: + attrs["help"] = option.help + if option.metavar is not None: + attrs["metavar"] = option.metavar + if option.envvar is not None: + attrs["envvar"] = option.envvar + if option.show_envvar: + attrs["show_envvar"] = True + if option.show_default is not None: + attrs["show_default"] = option.show_default + if option.hidden: + attrs["hidden"] = True + if option.default is not None: + attrs["default"] = option.default + decorated = click.version_option( + version, + *_lifecycle_param_decls(option), + **attrs, + )(version_parameter_source) + parameters = list(getattr(decorated, "__click_params__", ())) + if not parameters: + raise RuntimeError("Click did not create the requested version option.") + parameter = parameters[-1] + if not isinstance(getattr(parameter, "name", None), str) or not parameter.name: + raise RuntimeError( + "LifecycleOptions.version does not produce a stable Click destination." + ) + return parameter + + +def _normalized_parameter_declarations( + parameter: Any, + normalize: Callable[[str], str] | None, +) -> set[str]: + return { + _normalize_attached_option_declaration(str(declaration), normalize) + for declaration in ( + *tuple(getattr(parameter, "opts", ())), + *tuple(getattr(parameter, "secondary_opts", ())), + ) + } + + +def _normalized_parameter_declaration_sets( + parameter: Any, + normalize: Callable[[str], str] | None, +) -> tuple[set[str], set[str]]: + return ( + { + _normalize_attached_option_declaration(str(declaration), normalize) + for declaration in tuple(getattr(parameter, "opts", ())) + }, + { + _normalize_attached_option_declaration(str(declaration), normalize) + for declaration in tuple(getattr(parameter, "secondary_opts", ())) + }, + ) + + +def _reject_duplicate_lifecycle_declarations( + key: str, + parameter: Any, + normalize: Callable[[str], str] | None, +) -> None: + declarations = [ + _normalize_attached_option_declaration(str(declaration), normalize) + for declaration in ( + *tuple(getattr(parameter, "opts", ())), + *tuple(getattr(parameter, "secondary_opts", ())), + ) + ] + seen: set[str] = set() + duplicates: set[str] = set() + for declaration in declarations: + if declaration in seen: + duplicates.add(declaration) + seen.add(declaration) + if duplicates: + aliases = ", ".join(sorted(duplicates)) + raise RuntimeError( + f"Lifecycle option '{key}' repeats normalized declaration(s) {aliases}. " + f"Give LifecycleOptions.{key} unique aliases." + ) + + +def _lifecycle_collision_details( + parameter: Any, + existing_parameters: list[Any], + normalize: Callable[[str], str] | None, +) -> tuple[list[tuple[Any, set[str]]], list[Any]]: + declarations = _normalized_parameter_declarations(parameter, normalize) + alias_collisions: list[tuple[Any, set[str]]] = [] + destination_collisions: list[Any] = [] + for existing in existing_parameters: + overlapping = declarations & _normalized_parameter_declarations( + existing, + normalize, + ) + if overlapping: + alias_collisions.append((existing, overlapping)) + if ( + getattr(parameter, "name", None) + and getattr(existing, "name", None) == parameter.name + ): + destination_collisions.append(existing) + return alias_collisions, destination_collisions + + +def _implicit_help_declarations( + command: Any, + normalize: Callable[[str], str] | None, +) -> set[str]: + if not bool(getattr(command, "add_help_option", True)): + return set() + context_settings = dict(getattr(command, "context_settings", None) or {}) + declarations = context_settings.get("help_option_names", ("--help",)) + if declarations is None: + declarations = ("--help",) + return { + _normalize_attached_option_declaration(str(declaration), normalize) + for declaration in declarations + } + + +def _missing_adopted_declarations( + requested: Any, + existing: Any, + normalize: Callable[[str], str] | None, +) -> set[str]: + """Return configured aliases absent from the requested vendor flag polarity.""" + + requested_positive, requested_negative = _normalized_parameter_declaration_sets( + requested, + normalize, + ) + existing_positive, existing_negative = _normalized_parameter_declaration_sets( + existing, + normalize, + ) + return ( + requested_positive - existing_positive + ) | ( + requested_negative - existing_negative + ) + + +def _reject_implicit_help_collision( + key: str, + parameter: Any, + command: Any, + normalize: Callable[[str], str] | None, +) -> None: + collisions = _normalized_parameter_declarations( + parameter, + normalize, + ) & _implicit_help_declarations(command, normalize) + if collisions: + aliases = ", ".join(sorted(collisions)) + raise RuntimeError( + f"Lifecycle option '{key}' conflicts with Click's implicit help " + f"declaration(s) {aliases}. Disable or rename LifecycleOptions.{key}." + ) + + +def _native_lifecycle_collision_error( + key: str, + parameter: Any, + alias_collisions: list[tuple[Any, set[str]]], + destination_collisions: list[Any], + lifecycle_parameter_keys: dict[int, str] | None = None, +) -> RuntimeError: + lifecycle_parameter_keys = lifecycle_parameter_keys or {} + conflicting_keys = { + lifecycle_parameter_keys[id(existing)] + for existing in ( + *(existing for existing, _declarations in alias_collisions), + *destination_collisions, + ) + if id(existing) in lifecycle_parameter_keys + } + if conflicting_keys: + conflicting = ", ".join( + f"'{other_key}'" for other_key in sorted(conflicting_keys) + ) + return RuntimeError( + f"Lifecycle option '{key}' conflicts with lifecycle option(s) " + f"{conflicting}. Give LifecycleOptions.{key} a distinct declaration " + "and Click destination." + ) + if alias_collisions: + aliases = sorted( + declaration + for _existing, declarations in alias_collisions + for declaration in declarations + ) + detail = f"option declaration(s) {', '.join(aliases)}" + else: + detail = f"Click destination '{getattr(parameter, 'name', None)}'" + return RuntimeError( + f"Lifecycle option '{key}' conflicts with an application parameter at {detail}. " + f"Disable or rename LifecycleOptions.{key}." + ) + + +def _install_native_lifecycle_options( click: Any, command: Any, + lifecycle_options: LifecycleOptions, *, version: str | None, - added_parameters: list[Any], -) -> dict[str, str]: - option_specs: tuple[tuple[str, tuple[str, ...], dict[str, Any]], ...] = ( - ( - "log_file", - ("--log-file",), - { - "type": click.Path(dir_okay=False), - "help": "Override the persistent log file.", - }, - ), - ( - "keep_temp", - ("--keep-temp",), - { - "is_flag": True, - "default": None, - "help": "Preserve this run's temp directory.", - }, - ), - ( - "config", - ("--config",), - { - "type": _explicit_config_path_type(click), - "help": "Load an additional config file.", - }, - ), - ("environment", ("--environment",), {"help": "Set the CLI environment."}), - ( - "debug", - ("--debug",), - { - "is_flag": True, - "default": None, - "help": "Enable DEBUG logging on the user-facing stream.", - }, +) -> dict[str, _LifecycleBinding]: + parameters = getattr(command, "params", None) + if not isinstance(parameters, list): + raise TypeError("Click commands must expose a mutable params list.") + existing_parameters = list(parameters) + context_settings = dict(getattr(command, "context_settings", None) or {}) + normalize = context_settings.get("token_normalize_func") + bindings: dict[str, _LifecycleBinding] = {} + lifecycle_parameter_keys: dict[int, str] = {} + + lifecycle_parameters: dict[str, Any] = {} + version_parameter: Any | None = None + + for key in _NATIVE_LIFECYCLE_OPTION_ORDER: + option = getattr(lifecycle_options, key) + if option is None: + continue + parameter = _make_lifecycle_value_option(click, key, option) + _reject_duplicate_lifecycle_declarations(key, parameter, normalize) + _reject_implicit_help_collision(key, parameter, command, normalize) + alias_collisions, destination_collisions = _lifecycle_collision_details( + parameter, + existing_parameters, + normalize, + ) + if alias_collisions or destination_collisions: + raise _native_lifecycle_collision_error( + key, + parameter, + alias_collisions, + destination_collisions, + lifecycle_parameter_keys, + ) + parameters.append(parameter) + existing_parameters.append(parameter) + lifecycle_parameter_keys[id(parameter)] = key + lifecycle_parameters[key] = parameter + bindings[key] = _LifecycleBinding( + key=key, + parameter_name=str(parameter.name), + adopted=False, + ) + + version_option = lifecycle_options.version + if version is not None and version_option is not None: + parameter = _make_lifecycle_version_option(click, version_option, version) + _reject_duplicate_lifecycle_declarations("version", parameter, normalize) + _reject_implicit_help_collision("version", parameter, command, normalize) + alias_collisions, destination_collisions = _lifecycle_collision_details( + parameter, + existing_parameters, + normalize, + ) + if alias_collisions or destination_collisions: + raise _native_lifecycle_collision_error( + "version", + parameter, + alias_collisions, + destination_collisions, + lifecycle_parameter_keys, + ) + parameters.append(parameter) + version_parameter = parameter + + parameters[:] = [ + *([version_parameter] if version_parameter is not None else []), + *( + lifecycle_parameters[key] + for key in _NATIVE_LIFECYCLE_OPTION_ORDER + if key in lifecycle_parameters ), - ( - "quiet", - ("--quiet", "-q"), - { - "is_flag": True, - "default": None, - "help": "Suppress INFO logs on the user-facing stream.", - }, + *( + parameter + for parameter in existing_parameters + if id(parameter) not in lifecycle_parameter_keys ), + ] + + setattr(command, _CLICK_LIFECYCLE_BINDINGS_ATTRIBUTE, bindings) + return bindings + + +def _parameter_source_rank(source: Any) -> int: + name = getattr(source, "name", None) + return { + "COMMANDLINE": 4, + "PROMPT": 4, + "ENVIRONMENT": 3, + "DEFAULT_MAP": 2, + "DEFAULT": 1, + }.get(name, 0) + + +def _prefer_lifecycle_value( + current: _RawLifecycleValue | None, + candidate: _RawLifecycleValue | None, +) -> _RawLifecycleValue | None: + if candidate is None: + return current + if current is None: + return candidate + current_rank = _parameter_source_rank(current.source) + candidate_rank = _parameter_source_rank(candidate.source) + if candidate_rank > current_rank: + return candidate + if candidate_rank == current_rank and candidate.depth >= current.depth: + return candidate + return current + + +def _normalize_lifecycle_values( + click: Any, + raw: dict[str, _RawLifecycleValue], +) -> LifecycleValues: + def raw_value(key: str) -> Any: + selected = raw.get(key) + return None if selected is None else selected.value + + environment = raw_value("environment") + if environment is not None and not isinstance(environment, str): + raise click.UsageError( + "The configured lifecycle environment option must produce a string." + ) + + paths: dict[str, Path | None] = {} + for key in ("config", "log_file"): + value = raw_value(key) + if value is None: + paths[key] = None + continue + try: + raw_path = os.fspath(value) + except TypeError: + raw_path = None + if not isinstance(raw_path, str): + raise click.UsageError( + f"The configured lifecycle {key.replace('_', '-')} option must " + "produce a string or path-like object." + ) + paths[key] = Path(raw_path) + + return LifecycleValues( + debug=bool(raw_value("debug")), + quiet=bool(raw_value("quiet")), + environment=environment, + config=paths["config"], + keep_temp=bool(raw_value("keep_temp")), + log_file=paths["log_file"], + dry_run=bool(raw_value("dry_run")), + ) + + +def _resolve_lifecycle_values( + click: Any, + click_context: Any, + bindings: dict[str, _LifecycleBinding], + *, + extra_values: dict[str, _RawLifecycleValue] | None = None, +) -> _LifecycleResolution: + existing_resolution_map = click_context.meta.get( + _LIFECYCLE_RESOLUTION_META_KEY, + ) + if LIFECYCLE_META_KEY in click_context.meta: + existing_public_value = click_context.meta[LIFECYCLE_META_KEY] + framework_values = ( + tuple( + resolution.values + for resolution in existing_resolution_map.values() + if isinstance(resolution, _LifecycleResolution) + ) + if isinstance(existing_resolution_map, dict) + else () + ) + if not any( + existing_public_value is value + for value in framework_values + ): + raise click.UsageError( + f"Click context metadata key {LIFECYCLE_META_KEY!r} is reserved for " + "base-cli LifecycleValues. Rename the application metadata key." + ) + resolution_map = click_context.meta.setdefault( + _LIFECYCLE_RESOLUTION_META_KEY, + {}, + ) + parent = getattr(click_context, "parent", None) + parent_resolution = resolution_map.get(id(parent)) if parent is not None else None + raw = dict(parent_resolution.raw) if isinstance(parent_resolution, _LifecycleResolution) else {} + captures = click_context.meta.get(_LIFECYCLE_CAPTURE_META_KEY, {}) + context_captures = captures.get(id(click_context), {}) + depth = _context_depth(click_context) + + for key, binding in bindings.items(): + if binding.adopted: + candidate = _RawLifecycleValue( + value=getattr(click_context, "params", {}).get(binding.parameter_name), + source=click_context.get_parameter_source(binding.parameter_name), + depth=depth, + ) + else: + candidate = context_captures.get(key) + raw[key] = _prefer_lifecycle_value(raw.get(key), candidate) + + for key, candidate in (extra_values or {}).items(): + raw[key] = _prefer_lifecycle_value(raw.get(key), candidate) + + resolution = _LifecycleResolution( + values=_normalize_lifecycle_values(click, raw), + raw=raw, ) + resolution_map[id(click_context)] = resolution + click_context.meta[LIFECYCLE_META_KEY] = resolution.values + return resolution + + +def _standard_options_from_values(values: LifecycleValues) -> dict[str, Any]: + return { + key: getattr(values, key) + for key in _STANDARD_OPTION_KEYS + } + + +def _add_attached_standard_options( + click: Any, + command: Any, + *, + lifecycle_options: LifecycleOptions, + version: str | None, + added_parameters: list[Any], +) -> dict[str, _LifecycleBinding]: parameters = getattr(command, "params", None) if not isinstance(parameters, list): raise TypeError("Attached Click commands must expose a mutable params list.") + existing_parameters = list(parameters) existing_options = [ parameter - for parameter in parameters + for parameter in existing_parameters if getattr(parameter, "param_type_name", None) == "option" ] context_settings = dict(getattr(command, "context_settings", None) or {}) token_normalize_func = context_settings.get("token_normalize_func") - used_declarations = { - _normalize_attached_option_declaration( - str(declaration), + bindings: dict[str, _LifecycleBinding] = {} + bound_existing_parameters: dict[int, str] = {} + + for key in _ATTACHED_LIFECYCLE_OPTION_ORDER: + option = getattr(lifecycle_options, key) + if option is None: + continue + parameter = _make_lifecycle_value_option(click, key, option) + _reject_duplicate_lifecycle_declarations( + key, + parameter, token_normalize_func, ) - for parameter in existing_options - for declaration in ( - *tuple(getattr(parameter, "opts", ())), - *tuple(getattr(parameter, "secondary_opts", ())), + _reject_implicit_help_collision( + key, + parameter, + command, + token_normalize_func, ) - } - bindings: dict[str, str] = {} - bound_existing_parameters: dict[int, str] = {} - - for key, declarations, attrs in option_specs: normalized_primary = _normalize_attached_option_declaration( - declarations[0], + str(parameter.opts[0]), token_normalize_func, ) - existing = next( - ( - parameter - for parameter in existing_options - if normalized_primary - in { - _normalize_attached_option_declaration( - str(declaration), - token_normalize_func, - ) - for declaration in ( - *tuple(getattr(parameter, "opts", ())), - *tuple(getattr(parameter, "secondary_opts", ())), - ) - } - ), - None, - ) + primary_matches = [ + existing + for existing in existing_options + if normalized_primary + in _normalized_parameter_declarations( + existing, + token_normalize_func, + ) + ] + if len(primary_matches) > 1: + raise RuntimeError( + f"Lifecycle option '{key}' has ambiguous attached declaration " + f"'{parameter.opts[0]}'; multiple Click options already use it." + ) + existing = primary_matches[0] if primary_matches else None if existing is not None: + if option.name is not None and existing.name != option.name: + raise RuntimeError( + f"Existing '{parameter.opts[0]}' option uses Click destination " + f"{existing.name!r}, but LifecycleOptions.{key} requires " + f"{option.name!r}. Remove name= to adopt the vendor destination, " + "or rename/disable the lifecycle option." + ) previous_key = bound_existing_parameters.get(id(existing)) if previous_key is not None: raise RuntimeError( @@ -1330,7 +1975,55 @@ def _add_attached_standard_options( f"'{previous_key}' and '{key}' in one parameter; each " "base-cli lifecycle option must use a distinct parameter." ) - expected_flag = key in {"debug", "quiet", "keep_temp"} + alias_collisions, _destination_collisions = _lifecycle_collision_details( + parameter, + existing_parameters, + token_normalize_func, + ) + foreign_aliases = [ + (candidate, declarations) + for candidate, declarations in alias_collisions + if candidate is not existing + ] + if foreign_aliases: + aliases = sorted( + declaration + for _candidate, declarations in foreign_aliases + for declaration in declarations + ) + raise RuntimeError( + f"Lifecycle option '{key}' cannot adopt '{parameter.opts[0]}' " + f"because its other declaration(s) collide: {', '.join(aliases)}." + ) + missing_declarations = _missing_adopted_declarations( + parameter, + existing, + token_normalize_func, + ) + if missing_declarations: + aliases = ", ".join(sorted(missing_declarations)) + raise RuntimeError( + f"Existing '{parameter.opts[0]}' option is incompatible with " + f"LifecycleOptions.{key}; it does not expose configured " + f"declaration(s) {aliases} with the required flag polarity. " + "Add compatible aliases to the vendor option, or rename/disable " + "the lifecycle option." + ) + foreign_destinations = [ + candidate + for candidate in existing_parameters + if candidate is not existing + and getattr(candidate, "name", None) + == getattr(existing, "name", None) + ] + if foreign_destinations: + raise RuntimeError( + f"Lifecycle option '{key}' cannot adopt '{parameter.opts[0]}' " + f"because Click destination {existing.name!r} is also used by " + "another application parameter. Rename that destination or " + f"disable LifecycleOptions.{key}." + ) + expected_flag = key in _FLAG_LIFECYCLE_OPTION_KEYS is_flag = bool( getattr(existing, "is_flag", False) or getattr(existing, "count", False) @@ -1351,7 +2044,9 @@ def _add_attached_standard_options( } incompatible = ( is_flag != expected_flag + or bool(getattr(existing, "count", False)) or not getattr(existing, "expose_value", True) + or getattr(existing, "prompt", None) is not None or bool(getattr(existing, "multiple", False)) or getattr(existing, "nargs", 1) != 1 or normalized_primary in secondary_declarations @@ -1365,72 +2060,143 @@ def _add_attached_standard_options( ) if incompatible: raise RuntimeError( - f"Existing '{declarations[0]}' option is incompatible with " - "the base-cli lifecycle option of the same name." + f"Existing '{parameter.opts[0]}' option is incompatible with " + f"LifecycleOptions.{key}; rename or disable that lifecycle option." ) bound_existing_parameters[id(existing)] = key parameter_name = getattr(existing, "name", None) - if parameter_name: - bindings[key] = str(parameter_name) - continue - - available = tuple( - declaration - for declaration in declarations - if _normalize_attached_option_declaration( - declaration, - token_normalize_func, + if not parameter_name: + raise RuntimeError( + f"Existing '{parameter.opts[0]}' option has no Click destination." + ) + bindings[key] = _LifecycleBinding( + key=key, + parameter_name=str(parameter_name), + adopted=True, ) - not in used_declarations - ) - if not available: continue - def capture(click_context: Any, _parameter: Any, value: Any, *, option_key: str = key) -> Any: - values = click_context.meta.setdefault(_ATTACHED_STANDARD_OPTIONS_KEY, {}) - values[option_key] = value - return value - - option_attrs = dict(attrs) - auto_envvar_prefix = context_settings.get("auto_envvar_prefix") - if isinstance(auto_envvar_prefix, str) and auto_envvar_prefix: - option_attrs.setdefault( - "envvar", - f"{auto_envvar_prefix}_{key.upper()}", - ) - option_attrs.update(callback=capture, expose_value=False) - parameter = click.Option( - [*available, f"_base_cli_{key}"], - **option_attrs, + alias_collisions, destination_collisions = _lifecycle_collision_details( + parameter, + existing_parameters, + token_normalize_func, ) + if alias_collisions or destination_collisions: + raise _native_lifecycle_collision_error( + key, + parameter, + alias_collisions, + destination_collisions, + bound_existing_parameters, + ) parameters.append(parameter) added_parameters.append(parameter) + existing_parameters.append(parameter) existing_options.append(parameter) - used_declarations.update( - _normalize_attached_option_declaration( - declaration, + bound_existing_parameters[id(parameter)] = key + bindings[key] = _LifecycleBinding( + key=key, + parameter_name=str(parameter.name), + adopted=False, + ) + + version_option = lifecycle_options.version + if version is not None and version_option is not None: + parameter = _make_lifecycle_version_option(click, version_option, version) + _reject_duplicate_lifecycle_declarations( + "version", + parameter, + token_normalize_func, + ) + _reject_implicit_help_collision( + "version", + parameter, + command, + token_normalize_func, + ) + normalized_primary = _normalize_attached_option_declaration( + str(parameter.opts[0]), + token_normalize_func, + ) + primary_matches = [ + existing + for existing in existing_options + if normalized_primary + in _normalized_parameter_declarations( + existing, token_normalize_func, ) - for declaration in available - ) + ] + if len(primary_matches) > 1: + raise RuntimeError( + f"Lifecycle version declaration '{parameter.opts[0]}' is ambiguous." + ) + if primary_matches: + existing = primary_matches[0] + if version_option.name is not None and existing.name != version_option.name: + raise RuntimeError( + f"Existing '{parameter.opts[0]}' option uses Click destination " + f"{existing.name!r}, but LifecycleOptions.version requires " + f"{version_option.name!r}. Remove name= to adopt the vendor " + "destination, or rename/disable the lifecycle version option." + ) + compatible = bool( + getattr(existing, "is_flag", False) + and getattr(existing, "is_eager", False) + ) + if not compatible: + raise RuntimeError( + f"Existing '{parameter.opts[0]}' option is incompatible with " + "LifecycleOptions.version; rename or disable the lifecycle version option." + ) + alias_collisions, _destination_collisions = _lifecycle_collision_details( + parameter, + existing_parameters, + token_normalize_func, + ) + if any(candidate is not existing for candidate, _aliases in alias_collisions): + raise RuntimeError( + "LifecycleOptions.version has an alias used by another Click option." + ) + missing_declarations = _missing_adopted_declarations( + parameter, + existing, + token_normalize_func, + ) + if missing_declarations: + aliases = ", ".join(sorted(missing_declarations)) + raise RuntimeError( + "Existing lifecycle version option does not expose configured " + f"declaration(s) {aliases} with the required flag polarity. " + "Add compatible aliases to the vendor option, or rename/disable " + "the lifecycle version option." + ) + if any( + candidate is not existing + and getattr(candidate, "name", None) + == getattr(existing, "name", None) + for candidate in existing_parameters + ): + raise RuntimeError( + "LifecycleOptions.version adopts a Click destination used by " + "another application parameter. Rename that destination or " + "disable the lifecycle version option." + ) + return bindings - if ( - version is not None - and _normalize_attached_option_declaration("--version", token_normalize_func) - not in used_declarations - ): - def version_parameter_source() -> None: - return None - - decorated = click.version_option( - version, - "--version", - "_base_cli_version", - )(version_parameter_source) - click_parameters = list(getattr(decorated, "__click_params__", ())) - if not click_parameters: - raise RuntimeError("Click did not create the requested version option.") - parameter = click_parameters[-1] + alias_collisions, destination_collisions = _lifecycle_collision_details( + parameter, + existing_parameters, + token_normalize_func, + ) + if alias_collisions or destination_collisions: + raise _native_lifecycle_collision_error( + "version", + parameter, + alias_collisions, + destination_collisions, + bound_existing_parameters, + ) parameters.append(parameter) added_parameters.append(parameter) @@ -1450,48 +2216,6 @@ def _normalize_attached_option_declaration( return f"{prefix}{normalize(declaration[len(prefix):])}" -def _attached_standard_options( - click_context: Any, - attachment: _ClickAttachment, -) -> dict[str, Any]: - captured = getattr(click_context, "meta", {}).get( - _ATTACHED_STANDARD_OPTIONS_KEY, - {}, - ) - standard: dict[str, Any] = {} - params = getattr(click_context, "params", {}) - for key in _STANDARD_OPTION_KEYS: - parameter_name = attachment.standard_bindings.get(key) - if parameter_name: - standard[key] = params.get(parameter_name) - else: - standard[key] = captured.get(key) - return standard - - -def _validate_attached_standard_values(click: Any, standard: dict[str, Any]) -> None: - for key in ("config", "log_file"): - value = standard.get(key) - if value is None: - continue - try: - raw_path = os.fspath(value) - except TypeError: - raw_path = None - if not isinstance(raw_path, str): - declaration = "--config" if key == "config" else "--log-file" - raise click.UsageError( - f"Existing '{declaration}' option produced an incompatible value; " - "expected a string or path-like object." - ) - environment = standard.get("environment") - if environment is not None and not isinstance(environment, str): - raise click.UsageError( - "Existing '--environment' option produced an incompatible value; " - "expected a string." - ) - - def _selected_click_path( root_context: Any, selected_context: Any | None, @@ -1633,15 +2357,23 @@ def invoke(click_context: Any) -> Any: "inside another attached tree." ) if active is None and isinstance(attachment, _ClickAttachment): - standard = _attached_standard_options(click_context, attachment) - _validate_attached_standard_values(click, standard) - _validate_standard_options(click, standard) + resolution = _resolve_lifecycle_values( + click, + click_context, + attachment.standard_bindings, + ) + standard = _standard_options_from_values(resolution.values) + _validate_standard_options( + click, + standard, + attachment.lifecycle_options, + ) _capture_standard_options(standard, attachment.app) resource = _AttachedLifecycleResource( click, attachment, click_context, - standard, + resolution.values, ) _with_attached_lifecycle_resource(click_context, resource) if not _click_command_has_pending_children(click_context, command): @@ -1905,11 +2637,17 @@ def run_app( explicit_argv = argv is not None args = list(sys.argv[1:] if argv is None else argv) - leading_debug, leading_quiet = _leading_output_flags(args) + leading_debug, leading_quiet = _leading_output_flags( + args, + app.lifecycle_options, + ) state = _InvocationState( owner_app=app, debug=leading_debug, quiet=leading_quiet, + debug_option=_primary_lifecycle_declaration( + app.lifecycle_options.debug, + ), ) state_token = _INVOCATION_STATE.set(state) try: @@ -1981,7 +2719,13 @@ def _show_unexpected_error(state: _InvocationState, exc: Exception) -> None: traceback.print_exception(type(exc), exc, exc.__traceback__, file=sys.stderr) elif not traceback_visible: if state.options_parsed: - print("Re-run with --debug for a traceback.", file=sys.stderr) + if state.debug_option is not None: + print( + f"Re-run with {state.debug_option} for a traceback.", + file=sys.stderr, + ) + else: + print("Enable debug logging for a traceback.", file=sys.stderr) else: print("Diagnostic context was unavailable before option parsing completed.", file=sys.stderr) @@ -1997,14 +2741,61 @@ def _normalize_command_result(result: Any) -> int: ) -def _leading_output_flags(argv: list[str]) -> tuple[bool, bool]: +def _lifecycle_flag_declarations( + option: LifecycleOption | None, +) -> tuple[tuple[str, ...], tuple[str, ...]]: + if option is None: + return (), () + positive: list[str] = [] + negative: list[str] = [] + for declaration in option.param_decls: + if declaration.isidentifier(): + continue + split_char = ";" if declaration.startswith("/") else "/" + first, separator, second = declaration.partition(split_char) + positive.extend(option_aliases_from_decls((first.rstrip(),))) + if separator: + negative.extend(option_aliases_from_decls((second.lstrip(),))) + return tuple(positive), tuple(negative) + + +def _primary_lifecycle_declaration( + option: LifecycleOption | None, +) -> str | None: + declarations, _negative_declarations = _lifecycle_flag_declarations(option) + return next( + ( + declaration + for declaration in declarations + if declaration.startswith("--") + ), + declarations[0] if declarations else None, + ) + + +def _leading_output_flags( + argv: list[str], + lifecycle_options: LifecycleOptions, +) -> tuple[bool, bool]: + debug_positive, debug_negative = ( + set(declarations) + for declarations in _lifecycle_flag_declarations(lifecycle_options.debug) + ) + quiet_positive, quiet_negative = ( + set(declarations) + for declarations in _lifecycle_flag_declarations(lifecycle_options.quiet) + ) debug = False quiet = False for token in argv: - if token == "--debug": + if token in debug_positive: debug = True - elif token in ("--quiet", "-q"): + elif token in debug_negative: + debug = False + elif token in quiet_positive: quiet = True + elif token in quiet_negative: + quiet = False else: break return debug, quiet @@ -2087,33 +2878,6 @@ def decorator(func: Callable[..., Any]): return decorator -def _decorate_standard_options(click: Any, func: Callable[..., Any], version: str | None): - func = click.option("--log-file", type=click.Path(dir_okay=False), help="Override the persistent log file.")(func) - func = click.option("--keep-temp", is_flag=True, default=None, help="Preserve this run's temp directory.")(func) - func = click.option( - "--config", - type=_explicit_config_path_type(click), - help="Load an additional config file.", - )(func) - func = click.option("--environment", help="Set the CLI environment.")(func) - func = click.option( - "--debug", - is_flag=True, - default=None, - help="Enable DEBUG logging on the user-facing stream.", - )(func) - func = click.option( - "--quiet", - "-q", - is_flag=True, - default=None, - help="Suppress INFO logs on the user-facing stream.", - )(func) - if version is not None: - func = click.version_option(version)(func) - return func - - def _explicit_config_path_type(click: Any) -> Any: class ExplicitConfigPath(click.Path): def convert(self, value: Any, param: Any, ctx: Any) -> Path: @@ -2139,40 +2903,26 @@ def convert(self, value: Any, param: Any, ctx: Any) -> Path: ) -def _pop_standard_options(kwargs: dict[str, Any]) -> dict[str, Any]: - standard = {} - for key in _STANDARD_OPTION_KEYS: - standard[key] = kwargs.pop(key, None) - return standard - - -def _merge_standard_options(group_standard: dict[str, Any], command_standard: dict[str, Any]) -> dict[str, Any]: - merged = {} - for key in _STANDARD_OPTION_KEYS: - value = command_standard.get(key) - merged[key] = group_standard.get(key) if value is None else value - return merged - - -def _validate_standard_options(click: Any, standard: dict[str, Any]) -> None: +def _validate_standard_options( + click: Any, + standard: dict[str, Any], + lifecycle_options: LifecycleOptions, +) -> None: if standard.get("debug") and standard.get("quiet"): - raise click.UsageError("--debug and --quiet cannot be used together.") - - -def _group_standard_options(click: Any) -> dict[str, Any]: - context = click.get_current_context(silent=True) - parent = context.parent if context is not None else None - if parent is None or not isinstance(parent.obj, dict): - return {} - standard = parent.obj.get(_GROUP_STANDARD_OPTIONS_KEY) - return dict(standard) if isinstance(standard, dict) else {} + debug = _primary_lifecycle_declaration(lifecycle_options.debug) or "debug" + quiet = _primary_lifecycle_declaration(lifecycle_options.quiet) or "quiet" + raise click.UsageError(f"{debug} and {quiet} cannot be used together.") def _build_group_wrapper(click: Any) -> Callable[..., None]: @click.pass_context def group_wrapper(context: Any, **kwargs: Any) -> None: - obj = dict(context.obj) if isinstance(context.obj, dict) else {} - obj[_GROUP_STANDARD_OPTIONS_KEY] = _pop_standard_options(kwargs) - context.obj = obj + del kwargs + bindings = getattr( + context.command, + _CLICK_LIFECYCLE_BINDINGS_ATTRIBUTE, + {}, + ) + _resolve_lifecycle_values(click, context, bindings) return group_wrapper diff --git a/lib/python/base_cli/lifecycle_options.py b/lib/python/base_cli/lifecycle_options.py new file mode 100644 index 0000000..79fde42 --- /dev/null +++ b/lib/python/base_cli/lifecycle_options.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +__all__ = [ + "LIFECYCLE_META_KEY", + "LifecycleOption", + "LifecycleOptions", + "LifecycleValues", + "get_lifecycle_values", +] + + +LIFECYCLE_META_KEY = "base_cli.lifecycle" + + +@dataclass(frozen=True, init=False) +class LifecycleOption: + """Immutable public configuration for one lifecycle-owned Click option.""" + + param_decls: tuple[str, ...] + name: str | None + help: str | None + metavar: str | None + envvar: str | tuple[str, ...] | None + show_envvar: bool + show_default: bool | str | None + hidden: bool + default: Any + + def __init__( + self, + *param_decls: str, + name: str | None = None, + help: str | None = None, # pylint: disable=redefined-builtin + metavar: str | None = None, + envvar: str | tuple[str, ...] | list[str] | None = None, + show_envvar: bool = False, + show_default: bool | str | None = None, + hidden: bool = False, + default: Any = None, + ) -> None: + if not param_decls: + raise ValueError("LifecycleOption requires at least one option declaration.") + if not all(isinstance(declaration, str) and declaration for declaration in param_decls): + raise TypeError("LifecycleOption declarations must be non-empty strings.") + if not all(declaration.startswith(("-", "/")) for declaration in param_decls): + raise ValueError( + "LifecycleOption declarations must be visible Click option names " + "starting with '-' or '/'; use name= for the Click destination." + ) + if len(set(param_decls)) != len(param_decls): + raise ValueError("LifecycleOption declarations must be unique.") + if name is not None and ( + not isinstance(name, str) + or not name + or not name.isidentifier() + ): + raise TypeError( + "LifecycleOption name must be a non-empty Python identifier or None." + ) + if help is not None and not isinstance(help, str): + raise TypeError("LifecycleOption help must be a string or None.") + if metavar is not None and not isinstance(metavar, str): + raise TypeError("LifecycleOption metavar must be a string or None.") + if envvar is not None and not isinstance(envvar, str): + try: + envvar = tuple(envvar) + except TypeError as exc: + raise TypeError( + "LifecycleOption envvar must be a string, a sequence of strings, or None." + ) from exc + if not all(isinstance(value, str) and value for value in envvar): + raise TypeError( + "LifecycleOption envvar sequences must contain non-empty strings." + ) + if isinstance(envvar, str) and not envvar: + raise TypeError("LifecycleOption envvar must not be empty.") + if not isinstance(show_envvar, bool): + raise TypeError("LifecycleOption show_envvar must be a bool.") + if not ( + show_default is None + or isinstance(show_default, bool) + or isinstance(show_default, str) + ): + raise TypeError("LifecycleOption show_default must be a bool, string, or None.") + if not isinstance(hidden, bool): + raise TypeError("LifecycleOption hidden must be a bool.") + + object.__setattr__(self, "param_decls", tuple(param_decls)) + object.__setattr__(self, "name", name) + object.__setattr__(self, "help", help) + object.__setattr__(self, "metavar", metavar) + object.__setattr__(self, "envvar", envvar) + object.__setattr__(self, "show_envvar", show_envvar) + object.__setattr__(self, "show_default", show_default) + object.__setattr__(self, "hidden", hidden) + object.__setattr__(self, "default", default) + + +def _debug_option() -> LifecycleOption: + return LifecycleOption( + "--debug", + help="Enable DEBUG logging on the user-facing stream.", + ) + + +def _quiet_option() -> LifecycleOption: + return LifecycleOption( + "--quiet", + "-q", + help="Suppress INFO logs on the user-facing stream.", + ) + + +def _environment_option() -> LifecycleOption: + return LifecycleOption( + "--environment", + help="Set the CLI environment.", + ) + + +def _config_option() -> LifecycleOption: + return LifecycleOption( + "--config", + help="Load an additional config file.", + ) + + +def _keep_temp_option() -> LifecycleOption: + return LifecycleOption( + "--keep-temp", + help="Preserve this run's temp directory.", + ) + + +def _log_file_option() -> LifecycleOption: + return LifecycleOption( + "--log-file", + help="Override the persistent log file.", + ) + + +def _version_option() -> LifecycleOption: + return LifecycleOption("--version") + + +@dataclass(frozen=True) +class LifecycleOptions: + """Composable option policy used by native and attached applications.""" + + debug: LifecycleOption | None = field(default_factory=_debug_option) + quiet: LifecycleOption | None = field(default_factory=_quiet_option) + environment: LifecycleOption | None = field(default_factory=_environment_option) + config: LifecycleOption | None = field(default_factory=_config_option) + keep_temp: LifecycleOption | None = field(default_factory=_keep_temp_option) + log_file: LifecycleOption | None = field(default_factory=_log_file_option) + version: LifecycleOption | None = field(default_factory=_version_option) + dry_run: LifecycleOption | None = None + + def __post_init__(self) -> None: + for key in ( + "debug", + "quiet", + "environment", + "config", + "keep_temp", + "log_file", + "version", + "dry_run", + ): + value = getattr(self, key) + if value is not None and not isinstance(value, LifecycleOption): + raise TypeError( + f"LifecycleOptions.{key} must be a LifecycleOption or None." + ) + + +@dataclass(frozen=True) +class LifecycleValues: + """Normalized lifecycle values resolved for the active Click context.""" + + debug: bool = False + quiet: bool = False + environment: str | None = None + config: Path | None = None + keep_temp: bool = False + log_file: Path | None = None + dry_run: bool = False + + +def get_lifecycle_values(click_context: Any | None = None) -> LifecycleValues: + """Return normalized lifecycle values stored on an active Click context.""" + + if click_context is None: + try: + 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) + 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) + if not isinstance(value, LifecycleValues): + raise RuntimeError( + "Lifecycle option values are not available for this Click context." + ) + return value diff --git a/tests/test_lifecycle_options.py b/tests/test_lifecycle_options.py new file mode 100644 index 0000000..fee0205 --- /dev/null +++ b/tests/test_lifecycle_options.py @@ -0,0 +1,1356 @@ +from __future__ import annotations + +import importlib.util +import io +import tempfile +import unittest +from contextlib import redirect_stderr +from dataclasses import FrozenInstanceError, dataclass, replace +from pathlib import Path +from typing import Any + +import base_cli + + +def _option_for(command: Any, declaration: str) -> Any: + matches = [ + parameter + for parameter in command.params + if getattr(parameter, "param_type_name", None) == "option" + and declaration + in ( + *tuple(getattr(parameter, "opts", ())), + *tuple(getattr(parameter, "secondary_opts", ())), + ) + ] + if len(matches) != 1: + raise AssertionError( + f"expected exactly one option for {declaration!r}, found {len(matches)}" + ) + return matches[0] + + +def _option_count(command: Any, declaration: str) -> int: + return sum( + declaration + in ( + *tuple(getattr(parameter, "opts", ())), + *tuple(getattr(parameter, "secondary_opts", ())), + ) + for parameter in command.params + if getattr(parameter, "param_type_name", None) == "option" + ) + + +def _runner_env(home: Path, **values: str) -> dict[str, str]: + return { + "HOME": str(home), + "USERPROFILE": str(home), + "LOCALAPPDATA": str(home / "AppData" / "Local"), + "XDG_CACHE_HOME": str(home / ".cache"), + "BASE_CLI_CACHE_DIR": str(home / ".cache"), + **values, + } + + +@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") +class LifecycleOptionsTests(unittest.TestCase): + def test_public_option_policy_is_validated_immutable_and_copy_safe(self) -> None: + envvars = ["TOOL_TRACE", "LEGACY_TRACE"] + option = base_cli.LifecycleOption("--trace", envvar=envvars) + policy = base_cli.LifecycleOptions(debug=option) + envvars.append("MUTATED") + + self.assertEqual(option.envvar, ("TOOL_TRACE", "LEGACY_TRACE")) + with self.assertRaises(FrozenInstanceError): + policy.debug = None # type: ignore[misc] + with self.assertRaises(ValueError): + base_cli.LifecycleOption() + with self.assertRaises(ValueError): + base_cli.LifecycleOption("trace") + with self.assertRaises(ValueError): + base_cli.LifecycleOption("--trace", "--trace") + with self.assertRaises(TypeError): + base_cli.LifecycleOption("--trace", name="not-a-destination") + + def test_renamed_debug_and_quiet_control_preparser_diagnostics(self) -> None: + failure_detail = "renamed pre-parser diagnostic detail" + + def fail_display_command() -> str: + raise RuntimeError(failure_detail) + + profile = replace( + base_cli.CliProfile.generic(), + display_command=fail_display_command, + ) + lifecycle_options = base_cli.LifecycleOptions( + debug=base_cli.LifecycleOption("--trace"), + quiet=base_cli.LifecycleOption("--silent"), + ) + + for name, argv, traceback_expected in ( + ("renamed-debug", ["--trace"], True), + ("renamed-debug-quiet", ["--trace", "--silent"], False), + ): + with self.subTest(case=name): + app = base_cli.App( + name=name, + profile=profile, + log_to_file=False, + lifecycle_options=lifecycle_options, + ) + + @app.command() + def main(ctx: base_cli.Context) -> None: + del ctx + + stderr = io.StringIO() + with redirect_stderr(stderr): + status = base_cli.run_app(app, argv) + + output = stderr.getvalue() + self.assertEqual(status, 1) + self.assertIn("Error: Unexpected internal error.", output) + if traceback_expected: + self.assertIn("Traceback", output) + self.assertIn(failure_detail, output) + else: + self.assertNotIn("Traceback", output) + self.assertNotIn(failure_detail, output) + + def test_negative_debug_alias_does_not_enable_preparser_tracebacks(self) -> None: + failure_detail = "negative debug alias must not expose this traceback" + + def fail_display_command() -> str: + raise RuntimeError(failure_detail) + + profile = replace( + base_cli.CliProfile.generic(), + display_command=fail_display_command, + ) + app = base_cli.App( + name="negative-debug", + profile=profile, + log_to_file=False, + lifecycle_options=base_cli.LifecycleOptions( + debug=base_cli.LifecycleOption("--debug/--no-debug"), + ), + ) + + @app.command() + def main(ctx: base_cli.Context) -> None: + del ctx + + stderr = io.StringIO() + with redirect_stderr(stderr): + status = base_cli.run_app(app, ["--debug", "--no-debug"]) + + output = stderr.getvalue() + self.assertEqual(status, 1) + self.assertIn("Error: Unexpected internal error.", output) + self.assertNotIn("Traceback", output) + self.assertNotIn(failure_detail, output) + + def test_reclaimed_default_aliases_do_not_toggle_preparser_diagnostics(self) -> None: + failure_detail = "reclaimed alias must stay private" + + def fail_display_command() -> str: + raise RuntimeError(failure_detail) + + profile = replace( + base_cli.CliProfile.generic(), + display_command=fail_display_command, + ) + cases = ( + ( + "renamed-debug-user-default", + base_cli.LifecycleOptions( + debug=base_cli.LifecycleOption("--trace") + ), + "--debug", + ["--debug"], + False, + ), + ( + "disabled-debug-user-default", + base_cli.LifecycleOptions(debug=None), + "--debug", + ["--debug"], + False, + ), + ( + "renamed-quiet-user-default", + base_cli.LifecycleOptions( + debug=base_cli.LifecycleOption("--trace"), + quiet=base_cli.LifecycleOption("--silent"), + ), + "--quiet", + ["--trace", "--quiet"], + True, + ), + ( + "disabled-quiet-user-default", + base_cli.LifecycleOptions(quiet=None), + "--quiet", + ["--debug", "--quiet"], + True, + ), + ) + + for name, lifecycle_options, user_alias, argv, traceback_expected in cases: + with self.subTest(case=name): + app = base_cli.App( + name=name, + profile=profile, + log_to_file=False, + lifecycle_options=lifecycle_options, + ) + + @app.command() + @base_cli.option(user_alias, is_flag=True) + def main(ctx: base_cli.Context, **_kwargs: bool) -> None: + del ctx + + stderr = io.StringIO() + with redirect_stderr(stderr): + status = base_cli.run_app(app, argv) + + output = stderr.getvalue() + self.assertEqual(status, 1) + self.assertIn("Error: Unexpected internal error.", output) + if traceback_expected: + self.assertIn("Traceback", output) + self.assertIn(failure_detail, output) + else: + self.assertNotIn("Traceback", output) + self.assertNotIn(failure_detail, output) + + def test_explicit_default_options_preserve_implicit_app_help(self) -> None: + from click.testing import CliRunner + + implicit = base_cli.App( + name="default-contract", + version="1.2.3", + log_to_file=False, + ) + explicit = base_cli.App( + name="default-contract", + version="1.2.3", + log_to_file=False, + lifecycle_options=base_cli.LifecycleOptions(), + ) + + @implicit.command() + def implicit_main(ctx: base_cli.Context) -> None: + del ctx + + @explicit.command() + def explicit_main(ctx: base_cli.Context) -> None: + del ctx + + runner = CliRunner() + implicit_help = runner.invoke(implicit.click_command, ["--help"]) + explicit_help = runner.invoke(explicit.click_command, ["--help"]) + + self.assertEqual(implicit_help.exit_code, 0, implicit_help.output) + self.assertEqual(explicit_help.exit_code, 0, explicit_help.output) + self.assertEqual(explicit_help.output, implicit_help.output) + self.assertEqual( + [parameter.name for parameter in explicit.click_command.params], + [ + "version", + "quiet", + "debug", + "environment", + "config", + "keep_temp", + "log_file", + ], + ) + for declaration in ( + "--debug", + "--quiet", + "-q", + "--environment", + "--config", + "--keep-temp", + "--log-file", + "--version", + ): + self.assertEqual(_option_count(explicit.click_command, declaration), 1) + self.assertEqual(_option_count(explicit.click_command, "--dry-run"), 0) + + def test_group_and_leaf_help_have_stable_default_placement(self) -> None: + from click.testing import CliRunner + + app = base_cli.App( + name="placement", + version="2.0.0", + log_to_file=False, + ) + + @app.subcommand() + def status(ctx: base_cli.Context) -> None: + del ctx + + root = app.click_command + leaf = root.commands["status"] + runner = CliRunner() + root_help = runner.invoke(root, ["--help"]) + leaf_help = runner.invoke(root, ["status", "--help"]) + + self.assertEqual(root_help.exit_code, 0, root_help.output) + self.assertEqual(leaf_help.exit_code, 0, leaf_help.output) + for declaration in ( + "--debug", + "--quiet", + "--environment", + "--config", + "--keep-temp", + "--log-file", + ): + self.assertEqual(_option_count(root, declaration), 1) + self.assertEqual(_option_count(leaf, declaration), 1) + self.assertIn(declaration, root_help.output) + self.assertIn(declaration, leaf_help.output) + self.assertEqual(_option_count(root, "--version"), 1) + self.assertEqual(_option_count(leaf, "--version"), 0) + self.assertIn("--version", root_help.output) + self.assertNotIn("--version", leaf_help.output) + + def test_each_default_option_can_be_disabled_independently(self) -> None: + declarations = { + "debug": "--debug", + "quiet": "--quiet", + "environment": "--environment", + "config": "--config", + "keep_temp": "--keep-temp", + "log_file": "--log-file", + "version": "--version", + } + + for field_name, declaration in declarations.items(): + with self.subTest(option=field_name): + app = base_cli.App( + name=f"without-{field_name.replace('_', '-')}", + version="3.4.5", + log_to_file=False, + lifecycle_options=base_cli.LifecycleOptions( + **{field_name: None} + ), + ) + + @app.command() + def main(ctx: base_cli.Context) -> None: + del ctx + + command = app.click_command + self.assertEqual(_option_count(command, declaration), 0) + for other_name, other_declaration in declarations.items(): + if other_name != field_name: + self.assertEqual( + _option_count(command, other_declaration), + 1, + f"disabling {field_name} also removed {other_name}", + ) + + def test_disabled_lifecycle_alias_is_available_to_user_code(self) -> None: + from click.testing import CliRunner + + seen: list[tuple[bool, bool]] = [] + app = base_cli.App( + name="user-debug", + log_to_file=False, + lifecycle_options=base_cli.LifecycleOptions(debug=None), + ) + + @app.command() + @base_cli.option("--debug", is_flag=True) + def main(ctx: base_cli.Context, debug: bool) -> None: + seen.append((debug, ctx.debug)) + + with tempfile.TemporaryDirectory() as tmpdir: + result = CliRunner().invoke( + app.click_command, + ["--debug"], + env=_runner_env(Path(tmpdir)), + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(seen, [(True, False)]) + + def test_options_can_be_renamed_and_configured_independently(self) -> None: + from click.testing import CliRunner + + seen: list[base_cli.LifecycleValues] = [] + lifecycle_options = base_cli.LifecycleOptions( + debug=base_cli.LifecycleOption( + "--trace", + "-t", + name="diagnostic", + help="Enable diagnostic logging.", + envvar="COMPOSABLE_TRACE", + show_envvar=True, + show_default=True, + default=False, + ), + quiet=None, + environment=base_cli.LifecycleOption( + "--stage", + help="Select the deployment stage.", + metavar="TIER", + default="development", + show_default=True, + ), + keep_temp=base_cli.LifecycleOption( + "--preserve-work", + hidden=True, + ), + version=base_cli.LifecycleOption("--build-version"), + ) + app = base_cli.App( + name="configured-options", + version="7.8.9", + log_to_file=False, + lifecycle_options=lifecycle_options, + ) + + @app.command() + def main(ctx: base_cli.Context) -> None: + del ctx + seen.append(base_cli.get_lifecycle_values()) + + command = app.click_command + trace = _option_for(command, "--trace") + stage = _option_for(command, "--stage") + preserve = _option_for(command, "--preserve-work") + self.assertEqual(trace.name, "diagnostic") + self.assertEqual(trace.opts, ["--trace", "-t"]) + self.assertEqual(trace.help, "Enable diagnostic logging.") + self.assertEqual(trace.envvar, "COMPOSABLE_TRACE") + self.assertTrue(trace.show_envvar) + self.assertTrue(trace.show_default) + self.assertEqual(stage.name, "stage") + self.assertEqual(stage.metavar, "TIER") + self.assertEqual(stage.default, "development") + self.assertTrue(preserve.hidden) + + runner = CliRunner() + help_result = runner.invoke(command, ["--help"]) + version_result = runner.invoke(command, ["--build-version"]) + with tempfile.TemporaryDirectory() as tmpdir: + invoke_result = runner.invoke( + command, + [], + env=_runner_env(Path(tmpdir), COMPOSABLE_TRACE="1"), + ) + + self.assertEqual(help_result.exit_code, 0, help_result.output) + self.assertIn("--trace", help_result.output) + self.assertIn("-t", help_result.output) + self.assertIn("Enable diagnostic logging.", help_result.output) + self.assertIn("--stage TIER", help_result.output) + self.assertIn("development", help_result.output) + self.assertNotIn("--quiet", help_result.output) + self.assertNotIn("--preserve-work", help_result.output) + self.assertNotIn("--version", help_result.output) + self.assertIn("--build-version", help_result.output) + self.assertEqual(version_result.exit_code, 0, version_result.output) + self.assertIn("configured-options, version 7.8.9", version_result.output) + self.assertEqual(invoke_result.exit_code, 0, invoke_result.output) + self.assertEqual(len(seen), 1) + self.assertTrue(seen[0].debug) + self.assertEqual(seen[0].environment, "development") + + def test_renamed_option_derives_a_public_click_name(self) -> None: + app = base_cli.App( + name="derived-name", + log_to_file=False, + lifecycle_options=base_cli.LifecycleOptions( + debug=base_cli.LifecycleOption("--diagnostic-mode") + ), + ) + + @app.command() + def main(ctx: base_cli.Context) -> None: + del ctx + + option = _option_for(app.click_command, "--diagnostic-mode") + self.assertEqual(option.name, "diagnostic_mode") + self.assertFalse(option.expose_value) + + def test_lifecycle_alias_collisions_fail_before_command_materialization(self) -> None: + app = base_cli.App( + name="duplicate-lifecycle-alias", + lifecycle_options=base_cli.LifecycleOptions( + debug=base_cli.LifecycleOption("--diagnostic"), + quiet=base_cli.LifecycleOption("--diagnostic"), + ), + ) + + @app.command() + def main(ctx: base_cli.Context) -> None: + del ctx + + with self.assertRaisesRegex( + RuntimeError, + r"debug.*quiet|quiet.*debug", + ): + _ = app.click_command + + def test_lifecycle_public_name_collisions_fail_before_materialization(self) -> None: + app = base_cli.App( + name="duplicate-lifecycle-name", + lifecycle_options=base_cli.LifecycleOptions( + debug=base_cli.LifecycleOption("--diagnostic", name="shared"), + quiet=base_cli.LifecycleOption("--silent", name="shared"), + ), + ) + + @app.command() + def main(ctx: base_cli.Context) -> None: + del ctx + + with self.assertRaisesRegex( + RuntimeError, + r"debug.*quiet|quiet.*debug|shared", + ): + _ = app.click_command + + def test_configured_debug_quiet_conflict_names_visible_declarations(self) -> None: + from click.testing import CliRunner + + app = base_cli.App( + name="configured-output-conflict", + log_to_file=False, + lifecycle_options=base_cli.LifecycleOptions( + debug=base_cli.LifecycleOption("--trace"), + quiet=base_cli.LifecycleOption("--silent"), + ), + ) + + @app.command() + def main(ctx: base_cli.Context) -> None: + del ctx + + result = CliRunner().invoke( + app.click_command, + ["--trace", "--silent"], + ) + + self.assertEqual(result.exit_code, 2, result.output) + self.assertIn("--trace and --silent cannot be used together", result.output) + + def test_native_user_alias_collision_is_actionable(self) -> None: + app = base_cli.App(name="native-alias-collision") + + @app.command() + @base_cli.option("--debug", is_flag=True) + def main(ctx: base_cli.Context, debug: bool) -> None: + del ctx, debug + + with self.assertRaisesRegex( + RuntimeError, + r"debug.*(--debug|disable|rename)|(--debug|disable|rename).*debug", + ): + _ = app.click_command + + def test_native_user_destination_collision_is_actionable(self) -> None: + app = base_cli.App(name="native-destination-collision") + + @app.command() + @base_cli.option("--vendor-debug", "debug", is_flag=True) + def main(ctx: base_cli.Context, debug: bool) -> None: + del ctx, debug + + with self.assertRaisesRegex( + RuntimeError, + r"debug.*(destination|name|disable|rename)|(destination|name|disable|rename).*debug", + ): + _ = app.click_command + + def test_lifecycle_alias_cannot_replace_implicit_help(self) -> None: + app = base_cli.App( + name="implicit-help-collision", + lifecycle_options=base_cli.LifecycleOptions( + debug=base_cli.LifecycleOption("--help") + ), + ) + + @app.command() + def main(ctx: base_cli.Context) -> None: + del ctx + + with self.assertRaisesRegex( + RuntimeError, + r"help.*(debug|lifecycle)|(debug|lifecycle).*help", + ): + _ = app.click_command + + def test_attached_lifecycle_alias_cannot_replace_configured_help(self) -> None: + import click + + @click.command( + name="configured-help-collision", + context_settings={"help_option_names": ["-h", "--assist"]}, + ) + def command() -> None: + pass + + original_parameters = tuple(command.params) + app = base_cli.App( + name="configured-help-collision", + lifecycle_options=base_cli.LifecycleOptions( + debug=base_cli.LifecycleOption("--assist") + ), + ) + + with self.assertRaisesRegex( + RuntimeError, + r"assist.*(debug|help)|(debug|help).*assist", + ): + app.attach(command) + self.assertEqual(tuple(command.params), original_parameters) + + def test_attached_help_option_names_none_uses_click_default(self) -> None: + import click + from click.testing import CliRunner + + @click.command( + name="default-help-names", + context_settings={"help_option_names": None}, + ) + def command() -> None: + pass + + app = base_cli.App( + name="default-help-names", + lifecycle_options=base_cli.LifecycleOptions( + debug=base_cli.LifecycleOption("--trace") + ), + ) + app.attach(command) + + result = CliRunner().invoke(command, ["--help"]) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn("--help", result.output) + self.assertIn("--trace", result.output) + + def test_static_token_normalizer_participates_in_collision_checks(self) -> None: + app = base_cli.App( + name="normalized-alias-collision", + lifecycle_options=base_cli.LifecycleOptions( + debug=base_cli.LifecycleOption("--TRACE") + ), + ) + + @app.command(context_settings={"token_normalize_func": str.casefold}) + @base_cli.option("--trace", is_flag=True) + def main(ctx: base_cli.Context, trace: bool) -> None: + del ctx, trace + + with self.assertRaisesRegex( + RuntimeError, + r"trace.*(debug|lifecycle)|(debug|lifecycle).*trace", + ): + _ = app.click_command + + def test_expanded_and_normalized_lifecycle_aliases_must_be_unique(self) -> None: + cases = ( + ( + "expanded", + base_cli.LifecycleOption( + "--debug/--no-debug", + "--no-debug", + ), + ), + ( + "normalized", + base_cli.LifecycleOption("--TRACE", "--trace"), + ), + ) + + for name, option in cases: + with self.subTest(case=name): + app = base_cli.App( + name=f"duplicate-{name}", + lifecycle_options=base_cli.LifecycleOptions(debug=option), + ) + + @app.command(context_settings={"token_normalize_func": str.casefold}) + def main(ctx: base_cli.Context) -> None: + del ctx + + with self.assertRaisesRegex( + RuntimeError, + r"debug.*(repeat|unique)|(?:repeat|unique).*debug", + ): + _ = app.click_command + + def test_invalid_derived_destination_fails_consistently(self) -> None: + import click + + lifecycle_options = base_cli.LifecycleOptions( + debug=base_cli.LifecycleOption("--") + ) + native = base_cli.App( + name="invalid-native-destination", + lifecycle_options=lifecycle_options, + ) + + @native.command() + def main(ctx: base_cli.Context) -> None: + del ctx + + attached_command = click.Command( + name="invalid-attached-destination", + callback=lambda: None, + ) + attached = base_cli.App( + name="invalid-attached-destination", + lifecycle_options=lifecycle_options, + ) + + for pathway, operation in ( + ("native", lambda: native.click_command), + ("attached", lambda: attached.attach(attached_command)), + ): + with self.subTest(pathway=pathway), self.assertRaisesRegex( + RuntimeError, + r"(derive|determine|destination|name).*debug|debug.*(derive|determine|destination|name)", + ): + operation() + + def test_value_lifecycle_fields_cannot_become_dual_flags(self) -> None: + import click + + for key in ("environment", "config", "log_file"): + with self.subTest(field=key): + declaration = key.replace("_", "-") + lifecycle_options = base_cli.LifecycleOptions( + **{ + key: base_cli.LifecycleOption( + f"--{declaration}/--no-{declaration}" + ) + } + ) + native = base_cli.App( + name=f"native-{declaration}-shape", + lifecycle_options=lifecycle_options, + ) + + @native.command() + def main(ctx: base_cli.Context) -> None: + del ctx + + attached_command = click.Command( + name=f"attached-{declaration}-shape", + callback=lambda: None, + ) + attached_parameters = tuple(attached_command.params) + attached = base_cli.App( + name=attached_command.name, + lifecycle_options=lifecycle_options, + ) + + with self.assertRaisesRegex( + RuntimeError, + rf"{key}.*(?:shape|scalar)|(?:shape|scalar).*{key}", + ): + _ = native.click_command + with self.assertRaisesRegex( + RuntimeError, + rf"{key}.*(?:shape|scalar)|(?:shape|scalar).*{key}", + ): + attached.attach(attached_command) + self.assertEqual(tuple(attached_command.params), attached_parameters) + + def test_typed_meta_state_preserves_typed_dict_and_none_obj_identity(self) -> None: + import click + from click.testing import CliRunner + + @dataclass + class VendorState: + label: str + + objects: tuple[tuple[str, object | None], ...] = ( + ("typed", VendorState("kept")), + ("dict", {"vendor": "kept"}), + ("none", None), + ) + + for label, vendor_object in objects: + with self.subTest(obj=label), tempfile.TemporaryDirectory() as tmpdir: + seen: dict[str, Any] = {} + app = base_cli.App( + name=f"meta-{label}", + log_to_file=False, + ) + + @app.subcommand() + def status(ctx: base_cli.Context) -> None: + click_context = click.get_current_context() + values = base_cli.get_lifecycle_values() + seen.update( + obj=click_context.obj, + values=values, + meta_value=click_context.meta[base_cli.LIFECYCLE_META_KEY], + context=ctx, + ) + + result = CliRunner().invoke( + app.click_command, + ["--debug", "--environment", "stage", "status"], + obj=vendor_object, + env=_runner_env(Path(tmpdir)), + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertIs(seen["obj"], vendor_object) + self.assertIs(seen["values"], seen["meta_value"]) + self.assertIsInstance(seen["values"], base_cli.LifecycleValues) + self.assertTrue(seen["values"].debug) + self.assertFalse(seen["values"].quiet) + self.assertEqual(seen["values"].environment, "stage") + self.assertIsNone(seen["values"].config) + self.assertFalse(seen["values"].keep_temp) + self.assertIsNone(seen["values"].log_file) + self.assertFalse(seen["values"].dry_run) + self.assertTrue(base_cli.LIFECYCLE_META_KEY.startswith("base_cli.")) + + def test_group_leaf_precedence_uses_click_source_then_leaf_tiebreak(self) -> None: + from click.testing import CliRunner + + seen: list[str] = [] + app = base_cli.App(name="source-precedence", log_to_file=False) + + @app.subcommand() + def status(ctx: base_cli.Context) -> None: + seen.append(ctx.environment) + + cases = ( + ( + "root command line beats leaf environment", + ["--environment", "root-cli", "status"], + {"auto_envvar_prefix": "TOOL"}, + {"TOOL_STATUS_ENVIRONMENT": "leaf-env"}, + "root-cli", + ), + ( + "root environment beats leaf default map", + ["status"], + { + "auto_envvar_prefix": "TOOL", + "default_map": {"status": {"environment": "leaf-map"}}, + }, + {"TOOL_ENVIRONMENT": "root-env"}, + "root-env", + ), + ( + "leaf environment beats root default map", + ["status"], + { + "auto_envvar_prefix": "TOOL", + "default_map": {"environment": "root-map"}, + }, + {"TOOL_STATUS_ENVIRONMENT": "leaf-env"}, + "leaf-env", + ), + ( + "leaf default map wins equal-source tie", + ["status"], + { + "default_map": { + "environment": "root-map", + "status": {"environment": "leaf-map"}, + } + }, + {}, + "leaf-map", + ), + ( + "leaf command line wins equal-source tie", + [ + "--environment", + "root-cli", + "status", + "--environment", + "leaf-cli", + ], + {}, + {}, + "leaf-cli", + ), + ) + + runner = CliRunner() + for name, args, extra, case_env, expected in cases: + with self.subTest(case=name), tempfile.TemporaryDirectory() as tmpdir: + seen.clear() + result = runner.invoke( + app.click_command, + args, + env=_runner_env(Path(tmpdir), **case_env), + **extra, + ) + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(seen, [expected]) + + def test_attached_injected_option_honors_public_default_map_name(self) -> None: + import click + from click.testing import CliRunner + + seen: list[base_cli.LifecycleValues] = [] + + @click.command(name="attached-default-map") + def command() -> None: + seen.append(base_cli.get_lifecycle_values()) + + app = base_cli.App(name="attached-default-map", log_to_file=False) + app.attach(command) + debug = _option_for(command, "--debug") + + with tempfile.TemporaryDirectory() as tmpdir: + result = CliRunner().invoke( + command, + [], + default_map={"debug": True}, + env=_runner_env(Path(tmpdir)), + ) + + self.assertEqual(debug.name, "debug") + self.assertFalse(debug.expose_value) + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(len(seen), 1) + self.assertTrue(seen[0].debug) + + def test_attached_static_auto_envvar_prefix_reaches_lifecycle(self) -> None: + import click + from click.testing import CliRunner + + seen: list[bool] = [] + + @click.command( + name="attached-static-env", + context_settings={"auto_envvar_prefix": "STATIC"}, + ) + def command() -> None: + seen.append(base_cli.get_lifecycle_values().debug) + + app = base_cli.App(name="attached-static-env", log_to_file=False) + app.attach(command) + + with tempfile.TemporaryDirectory() as tmpdir: + result = CliRunner().invoke( + command, + [], + env=_runner_env(Path(tmpdir), STATIC_DEBUG="1"), + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(seen, [True]) + + def test_attached_runtime_auto_envvar_prefix_reaches_lifecycle(self) -> None: + import click + from click.testing import CliRunner + + seen: list[bool] = [] + + @click.command(name="attached-runtime-env") + def command() -> None: + seen.append(base_cli.get_lifecycle_values().debug) + + app = base_cli.App(name="attached-runtime-env", log_to_file=False) + app.attach(command) + + with tempfile.TemporaryDirectory() as tmpdir: + result = CliRunner().invoke( + command, + [], + auto_envvar_prefix="RUNTIME", + env=_runner_env(Path(tmpdir), RUNTIME_DEBUG="1"), + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(seen, [True]) + + def test_attached_renamed_public_name_drives_default_map_and_auto_env(self) -> None: + import click + from click.testing import CliRunner + + seen: list[bool] = [] + + @click.command(name="attached-renamed-source") + def command() -> None: + seen.append(base_cli.get_lifecycle_values().debug) + + app = base_cli.App( + name="attached-renamed-source", + log_to_file=False, + lifecycle_options=base_cli.LifecycleOptions( + debug=base_cli.LifecycleOption( + "--trace", + name="diagnostic", + ) + ), + ) + app.attach(command) + trace = _option_for(command, "--trace") + runner = CliRunner() + + with tempfile.TemporaryDirectory() as tmpdir: + map_result = runner.invoke( + command, + [], + default_map={"diagnostic": True}, + env=_runner_env(Path(tmpdir)), + ) + env_result = runner.invoke( + command, + [], + auto_envvar_prefix="TOOL", + env=_runner_env(Path(tmpdir), TOOL_DIAGNOSTIC="1"), + ) + + self.assertEqual(trace.name, "diagnostic") + self.assertEqual(map_result.exit_code, 0, map_result.output) + self.assertEqual(env_result.exit_code, 0, env_result.output) + self.assertEqual(seen, [True, True]) + + def test_attached_compatible_option_is_adopted_without_callback_mutation(self) -> None: + import click + from click.testing import CliRunner + + callback_values: list[bool] = [] + command_values: list[tuple[bool, bool]] = [] + + def vendor_callback( + _context: click.Context, + _parameter: click.Parameter, + value: bool, + ) -> bool: + callback_values.append(value) + return value + + @click.command(name="attached-adoption") + @click.option( + "--trace", + "vendor_debug", + is_flag=True, + callback=vendor_callback, + ) + def command(vendor_debug: bool) -> None: + command_values.append( + (vendor_debug, base_cli.get_lifecycle_values().debug) + ) + + vendor_option = _option_for(command, "--trace") + original_callback = vendor_option.callback + app = base_cli.App( + name="attached-adoption", + log_to_file=False, + lifecycle_options=base_cli.LifecycleOptions( + debug=base_cli.LifecycleOption("--trace") + ), + ) + app.attach(command) + + self.assertIs(_option_for(command, "--trace"), vendor_option) + self.assertIs(vendor_option.callback, original_callback) + self.assertEqual(vendor_option.name, "vendor_debug") + self.assertEqual(_option_count(command, "--trace"), 1) + + with tempfile.TemporaryDirectory() as tmpdir: + result = CliRunner().invoke( + command, + [], + default_map={"vendor_debug": True}, + env=_runner_env(Path(tmpdir)), + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(callback_values, [True]) + self.assertEqual(command_values, [(True, True)]) + + def test_attached_adoption_rejects_a_conflicting_explicit_destination(self) -> None: + import click + + @click.command(name="attached-explicit-destination") + @click.option("--trace", "vendor_debug", is_flag=True) + def command(vendor_debug: bool) -> None: + del vendor_debug + + original_parameters = tuple(command.params) + app = base_cli.App( + name="attached-explicit-destination", + lifecycle_options=base_cli.LifecycleOptions( + debug=base_cli.LifecycleOption( + "--trace", + name="diagnostic", + ) + ), + ) + + with self.assertRaisesRegex( + RuntimeError, + r"vendor_debug.*diagnostic|diagnostic.*vendor_debug", + ): + app.attach(command) + + self.assertEqual(tuple(command.params), original_parameters) + + def test_attached_adoption_rejects_missing_configured_aliases(self) -> None: + import click + + @click.command(name="attached-missing-alias") + @click.option("--trace", is_flag=True) + def command(trace: bool) -> None: + del trace + + original_parameters = tuple(command.params) + app = base_cli.App( + name="attached-missing-alias", + lifecycle_options=base_cli.LifecycleOptions( + debug=base_cli.LifecycleOption("--trace", "-t") + ), + ) + + with self.assertRaisesRegex( + RuntimeError, + r"-t.*(expose|alias)|(?:expose|alias).*-t", + ): + app.attach(command) + + self.assertEqual(tuple(command.params), original_parameters) + + def test_attached_adoption_requires_matching_alias_polarity(self) -> None: + import click + + cases = ( + ( + "configured-negative", + ("--debug", "--no-debug"), + base_cli.LifecycleOption("--debug/--no-debug"), + ), + ( + "vendor-negative", + ("--no-debug/--debug",), + base_cli.LifecycleOption("--no-debug", "--debug"), + ), + ) + + for name, vendor_declarations, option in cases: + with self.subTest(case=name): + + @click.command(name=f"attached-polarity-{name}") + @click.option(*vendor_declarations, is_flag=True) + def command(**_kwargs: bool) -> None: + pass + + original_parameters = tuple(command.params) + app = base_cli.App( + name=f"attached-polarity-{name}", + lifecycle_options=base_cli.LifecycleOptions(debug=option), + ) + + with self.assertRaisesRegex( + RuntimeError, + r"polarity.*(?:debug|no-debug)|(?:debug|no-debug).*polarity", + ): + app.attach(command) + + self.assertEqual(tuple(command.params), original_parameters) + + def test_attached_version_adoption_enforces_name_and_aliases(self) -> None: + import click + + cases = ( + ( + "destination", + base_cli.LifecycleOption( + "--build-version", + name="release", + ), + r"vendor_version.*release|release.*vendor_version", + ), + ( + "alias", + base_cli.LifecycleOption("--build-version", "-V"), + r"-V.*(expose|alias)|(?:expose|alias).*-V", + ), + ) + + for name, version_option, message in cases: + with self.subTest(case=name): + + @click.command(name=f"attached-version-{name}") + @click.version_option( + "1.2.3", + "--build-version", + "vendor_version", + ) + def command() -> None: + pass + + original_parameters = tuple(command.params) + app = base_cli.App( + name=f"attached-version-{name}", + version="1.2.3", + lifecycle_options=base_cli.LifecycleOptions( + version=version_option + ), + ) + + with self.assertRaisesRegex(RuntimeError, message): + app.attach(command) + + self.assertEqual(tuple(command.params), original_parameters) + + def test_attached_configured_options_appear_only_on_root_help(self) -> None: + import click + from click.testing import CliRunner + + @click.group(name="attached-placement") + def root() -> None: + pass + + @root.command(name="status") + def status() -> None: + pass + + app = base_cli.App( + name="attached-placement", + log_to_file=False, + lifecycle_options=base_cli.LifecycleOptions( + debug=base_cli.LifecycleOption("--trace"), + quiet=None, + ), + ) + app.attach(root) + runner = CliRunner() + root_help = runner.invoke(root, ["--help"]) + leaf_help = runner.invoke(root, ["status", "--help"]) + + self.assertEqual(root_help.exit_code, 0, root_help.output) + self.assertEqual(leaf_help.exit_code, 0, leaf_help.output) + self.assertIn("--trace", root_help.output) + self.assertNotIn("--quiet", root_help.output) + self.assertNotIn("--trace", leaf_help.output) + self.assertNotIn("--quiet", leaf_help.output) + self.assertEqual(_option_count(root, "--trace"), 1) + self.assertEqual(_option_count(status, "--trace"), 0) + + def test_reserved_lifecycle_meta_key_is_never_overwritten(self) -> None: + import click + from click.testing import CliRunner + + def occupy_meta( + click_context: click.Context, + _parameter: click.Parameter, + value: bool, + ) -> bool: + click_context.meta[base_cli.LIFECYCLE_META_KEY] = "vendor-owned" + return value + + @click.command(name="reserved-meta") + @click.option("--vendor", is_flag=True, callback=occupy_meta) + def command(vendor: bool) -> None: + del vendor + + app = base_cli.App(name="reserved-meta", log_to_file=False) + app.attach(command) + + result = CliRunner().invoke(command, ["--vendor"]) + + self.assertEqual(result.exit_code, 2, result.output) + self.assertIn("base_cli.lifecycle", result.output) + self.assertIn("reserved", result.output) + + def test_attached_vendor_dry_run_does_not_opt_in_by_default(self) -> None: + import click + from click.testing import CliRunner + + seen: list[tuple[bool, bool, bool]] = [] + + @click.command(name="vendor-dry-run") + @click.option("--dry-run", is_flag=True) + def command(dry_run: bool) -> None: + context = base_cli.get_current_context() + seen.append( + ( + dry_run, + context.dry_run, + base_cli.get_lifecycle_values().dry_run, + ) + ) + + app = base_cli.App(name="vendor-dry-run", log_to_file=False) + app.attach(command) + + with tempfile.TemporaryDirectory() as tmpdir: + result = CliRunner().invoke( + command, + ["--dry-run"], + env=_runner_env(Path(tmpdir)), + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(seen, [(True, False, False)]) + + def test_attached_dry_run_can_be_enabled_and_renamed(self) -> None: + import click + from click.testing import CliRunner + + seen: list[tuple[bool, bool, Path | None, bool]] = [] + + @click.command(name="attached-preview") + def command() -> None: + context = base_cli.get_current_context() + values = base_cli.get_lifecycle_values() + seen.append( + ( + context.dry_run, + values.dry_run, + context.log_file, + context.temp_dir.exists(), + ) + ) + + app = base_cli.App( + name="attached-preview", + lifecycle_options=base_cli.LifecycleOptions( + dry_run=base_cli.LifecycleOption("--preview") + ), + ) + app.attach(command) + + with tempfile.TemporaryDirectory() as tmpdir: + result = CliRunner().invoke( + command, + ["--preview"], + env=_runner_env(Path(tmpdir)), + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(seen, [(True, True, None, False)]) + + def test_native_conventional_dry_run_conflicts_with_global_policy(self) -> None: + app = base_cli.App( + name="duplicate-dry-run-source", + lifecycle_options=base_cli.LifecycleOptions( + dry_run=base_cli.LifecycleOption("--simulate") + ), + ) + + @app.command() + @base_cli.option("--preview", "dry_run", is_flag=True) + def main(ctx: base_cli.Context, dry_run: bool) -> None: + del ctx, dry_run + + with self.assertRaisesRegex(RuntimeError, "only one dry-run source"): + _ = app.click_command + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 2f891c1..971e911 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 command_filters, command_protocol, history +from base_cli import command_filters, command_protocol, history, lifecycle_options class PublicApiTests(unittest.TestCase): @@ -40,7 +40,12 @@ def test_facade_exports_supported_modules_functions_and_types(self) -> None: "dumps_record", "dumps_records", "get_command_app", + "get_lifecycle_values", "history", + "LIFECYCLE_META_KEY", + "LifecycleOption", + "LifecycleOptions", + "LifecycleValues", "loads_records", "normalize_command_filter", "normalize_command_filters", @@ -71,6 +76,16 @@ def test_module_all_surfaces_are_explicit(self) -> None: "register_record_schema", }, ) + self.assertEqual( + set(lifecycle_options.__all__), + { + "LIFECYCLE_META_KEY", + "LifecycleOption", + "LifecycleOptions", + "LifecycleValues", + "get_lifecycle_values", + }, + ) self.assertIn("write_primary_record", history.__all__) self.assertNotIn("lock_history_file", history.__all__) self.assertNotIn("write_all", history.__all__)