From 1f075b78308bd04c5c58e20fb9798d4f7b2aa0d5 Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Sat, 26 Sep 2026 09:40:24 -0300 Subject: [PATCH 1/4] refactor(cli): make cli.model_command the single model-command owner Delete the duplicate u.Cli.build_model_command Builder (and its SignatureCarrier) that passed raw field annotations to Typer, plus the u.Cli.derive_model copy of cli.derive_model. No workspace member consumes either; u.Cli.model_source_data stays as the primitive cli.derive_model uses. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/flext_cli/_utilities/model_commands.py | 133 +----------------- ...flextclipubliccontractscoverage_part_01.py | 15 +- 2 files changed, 10 insertions(+), 138 deletions(-) diff --git a/src/flext_cli/_utilities/model_commands.py b/src/flext_cli/_utilities/model_commands.py index 7610c866..a67f674a 100644 --- a/src/flext_cli/_utilities/model_commands.py +++ b/src/flext_cli/_utilities/model_commands.py @@ -1,110 +1,19 @@ -"""Thin model-command adapters shared through ``u.Cli``. +"""Model-source helpers shared through ``u.Cli``. -NOTE (multi-agent): handlers are annotated as the structural type -``Callable[[M], t.JsonValue]`` (the exact contract of -``p.Cli.ModelCommandHandler``) because mypy degrades inherited nested -protocol classes reached through the ``p.Cli`` facade MRO to ``Any`` -(pyrefly/pyright resolve them). Structural typing is the FLEXT default and -keeps all three checkers precise without casts. +Model-backed commands have one owner, ``cli.model_command``; model +derivation has one owner, ``cli.derive_model``. This module keeps only the +source-extraction primitive that ``cli.derive_model`` consumes. """ from __future__ import annotations -import inspect -from collections.abc import Callable, Mapping -from typing import Protocol, cast +from collections.abc import Mapping -from flext_cli import p, settings, t -from flext_core import m +from flext_cli import t class FlextCliUtilitiesModelCommands: - """Model command methods exposed directly on ``u.Cli``.""" - - class SignatureCarrier(Protocol): - """A callable whose CLI parameters typer/click read from `__signature__`. - - `__signature__` is a documented runtime attribute of function objects, but - it is absent from the static `FunctionType`, so assigning it directly is - rejected by the type checkers. Declaring it here states the contract the - generated command actually satisfies, with no suppression. - """ - - __signature__: inspect.Signature - __annotations__: dict[str, object] - - class Builder[M: t.Cli.ModelLike]: - """Thin builder for direct model-backed command callables.""" - - def __init__( - self, - model_class: t.ModelClass[M], - handler: Callable[[M], t.JsonValue], - settings: t.Cli.ModelLike | None = None, - ) -> None: - """Store the canonical inputs for deferred command construction.""" - super().__init__() - self.model_class = model_class - self.handler = handler - self.settings = settings - - def _resolve_default(self, field_info: m.FieldInfo) -> t.Cli.CliValue | type: - if field_info.is_required(): - return inspect.Parameter.empty - # NOTE (multi-agent): ``FieldInfo.get_default`` is typed ``Any`` - # in pydantic; the declared union is the real runtime contract. - return cast( - "t.Cli.CliValue | type", - field_info.get_default(call_default_factory=True), - ) - - def build(self) -> t.Cli.CliCommand: - """Build a direct callable with a real runtime signature.""" - model_fields = self.model_class.model_fields - parameters = [ - inspect.Parameter( - name=field_name, - kind=inspect.Parameter.KEYWORD_ONLY, - default=self._resolve_default(field_info), - annotation=getattr(field_info, "annotation", None) or str, - ) - for field_name, field_info in model_fields.items() - if getattr(field_info, "exclude", None) is not True - ] - signature = inspect.Signature(parameters) - - # NOTE (multi-agent): ``self.settings`` (per-command, may be any - # model or None) falls back to the module settings singleton. - # Overrides apply only when the effective settings is a full - # Settings protocol (has update_global) — a plain model skips them. - effective_settings = ( - self.settings if self.settings is not None else settings - ) - - def command(**kwargs: t.Cli.CliValue) -> t.JsonValue: - if isinstance(effective_settings, p.Cli.Settings): - settings_fields = effective_settings.model_dump() - applicable_overrides = { - field_name: field_value - for field_name, field_value in kwargs.items() - if field_name in settings_fields - } - if applicable_overrides: - effective_settings.update_global(**applicable_overrides) - model = self.model_class.model_validate(kwargs) - return self.handler(model) - - # typer/click read the CLI parameters off `__signature__`; the - # protocol above declares that contract so no cast is suppressed. - typed_command = cast( - "FlextCliUtilitiesModelCommands.SignatureCarrier", command - ) - typed_command.__signature__ = signature - typed_command.__annotations__ = { - parameter.name: parameter.annotation for parameter in parameters - } - typed_command.__annotations__["return"] = t.JsonValue - return command + """Model-source methods exposed directly on ``u.Cli``.""" @staticmethod def model_source_data( @@ -123,33 +32,5 @@ def model_source_data( } return t.Cli.JSON_MAPPING_ADAPTER.validate_python(filtered_payload) - @classmethod - def derive_model[M: t.Cli.ModelLike]( - cls, - model_cls: type[M], - *sources: t.Cli.ModelSource, - overrides: t.ScalarMapping | None = None, - ) -> M: - """Derive a target model from ordered model/mapping sources.""" - merged: t.MutableJsonMapping = {} - for source in sources: - merged.update(cls.model_source_data(model_cls, source)) - if overrides is not None: - merged.update(cls.model_source_data(model_cls, overrides)) - validated: M = model_cls.model_validate(merged) - return validated - - @staticmethod - def build_model_command[M: t.Cli.ModelLike]( - model_class: t.ModelClass[M], - handler: Callable[[M], t.JsonValue], - settings: t.Cli.ModelLike | None = None, - ) -> t.Cli.CliCommand: - """Build a model command through the canonical CLI service.""" - # NOTE (multi-agent): All model-class ingress uses t.ModelClass. - return FlextCliUtilitiesModelCommands.Builder( - model_class=model_class, handler=handler, settings=settings - ).build() - __all__: list[str] = ["FlextCliUtilitiesModelCommands"] diff --git a/tests/unit/_cases/test_public_contracts_cov/testsflextclipubliccontractscoverage_part_01.py b/tests/unit/_cases/test_public_contracts_cov/testsflextclipubliccontractscoverage_part_01.py index a5fb8798..9a9a27e0 100644 --- a/tests/unit/_cases/test_public_contracts_cov/testsflextclipubliccontractscoverage_part_01.py +++ b/tests/unit/_cases/test_public_contracts_cov/testsflextclipubliccontractscoverage_part_01.py @@ -55,16 +55,8 @@ def test_public_facade_and_settings_contract(self) -> None: tm.that(facade_result.value.status, eq=(c.Cli.ServiceStatus.OPERATIONAL)) tm.that(facade_result.value.service, eq=c.Cli.FLEXT_CLI) - def test_public_model_command_utility_contract(self) -> None: - """Verify that public model command utility contract.""" - command_settings = self._CommandModel(label="configured", debug=True) - - def handler(model: TestsFlextCliPublicContractsCoverage._CommandModel) -> str: - return f"{model.label}:{model.debug}" - - command = u.Cli.build_model_command( - self._CommandModel, handler, settings=command_settings - ) + def test_public_model_source_data_contract(self) -> None: + """Model sources contribute only target fields that carry a value.""" tm.that( u.Cli.model_source_data( self._CommandModel, self._CommandSource(label="mapped", debug=None) @@ -72,7 +64,7 @@ def handler(model: TestsFlextCliPublicContractsCoverage._CommandModel) -> str: eq={"label": "mapped"}, ) - derived = u.Cli.derive_model( + derived = cli.derive_model( self._CommandModel, {"label": "base"}, {"debug": True}, @@ -81,7 +73,6 @@ def handler(model: TestsFlextCliPublicContractsCoverage._CommandModel) -> str: tm.that(derived.label, eq="override") tm.that(derived.debug, eq=True) - tm.that(command(label="runtime", debug=True), eq="runtime:True") __all__: list[str] = ["TestsFlextCliPublicContractsCoverage"] From 787abdfbd94976514d2af6e52a10f29c82f3053b Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Sat, 26 Sep 2026 09:40:29 -0300 Subject: [PATCH 2/4] fix(prompts): let input-port failures escape with their cause Remove the CLI_SAFE_EXCEPTIONS catch-and-normalize from confirm and the _guarded/_fatal helpers behind prompt, prompt_choice and prompt_password. A reader exception now propagates unchanged; cancellation (KeyboardInterrupt) and end of input (EOFError) stay declared r.fail outcomes and carry the exception. prompt_choice never read input, so its message parameter, used only by the removed failure log, is dropped. Tests assert propagation through the public service with scripted input ports. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/flext_cli/services/_prompts_support.py | 55 ++------------- src/flext_cli/services/prompts.py | 67 +++++-------------- tests/protocols.py | 2 +- .../testsflextcliprompts_part_01.py | 13 ---- .../testsflextcliprompts_part_02.py | 16 ++--- tests/unit/test_prompts_cov.py | 40 ++++++----- 6 files changed, 50 insertions(+), 143 deletions(-) diff --git a/src/flext_cli/services/_prompts_support.py b/src/flext_cli/services/_prompts_support.py index 5ebca262..435beb8f 100644 --- a/src/flext_cli/services/_prompts_support.py +++ b/src/flext_cli/services/_prompts_support.py @@ -1,22 +1,16 @@ """Prompt service support primitives. -NOTE (multi-agent): mro-i6nq.13 — moved here from the removed -``_prompts_parts/flextcliprompts_support.py`` so the whole numbered -``_prompts_parts`` package could be eliminated. Adds the ``_guarded`` DRY -helper that collapses the repeated ``try/except CLI_SAFE_EXCEPTIONS -> _fatal --> r.fail(fmt)`` idiom into one canonical ``u.guard_result`` boundary. +Input-port failures are not caught here: the reader's exception escapes the +prompt method with its cause. Only declared prompt outcomes return ``r``. """ from __future__ import annotations import getpass -from typing import TYPE_CHECKING, Annotated, Self +from typing import Annotated, Self from flext_cli import c, m, p, r, s, settings, t, u -if TYPE_CHECKING: - from collections.abc import Callable - class _PromptInputReaderDefault: """Resolve the built-in input reader without storing a method descriptor.""" @@ -76,44 +70,6 @@ def _is_test_env(self) -> bool: """ return u.Cli.cli_test_env(settings) - def _guarded[TResult]( - self, - operation: str, - message: str, - work: Callable[[], p.Result[TResult]], - *, - consequence: str, - error_format: str, - ) -> p.Result[TResult]: - """Run a Result-returning prompt operation behind one exception boundary. - - Collapses the canonical ``try/except CLI_SAFE_EXCEPTIONS -> _fatal -> - r.fail(fmt)`` idiom shared by every interactive prompt method into a - single ``u.guard_result`` call plus structured fatal logging. - """ - guarded = u.guard_result( - work, catch=c.Cli.CLI_SAFE_EXCEPTIONS, op_name=operation - ) - if guarded.success: - return guarded - exc = guarded.error or operation - self._fatal(operation, message, Exception(exc), consequence) - return r[TResult].fail(error_format.format(error=exc)) - - def _fatal( - self, operation: str, message: str, exc: Exception, consequence: str - ) -> None: - self._log( - c.LogLevel.ERROR, - f"FATAL ERROR during {operation} - operation aborted", - operation=operation, - prompt_message=message, - error=str(exc), - error_type=type(exc).__name__, - consequence=consequence, - severity="critical", - ) - def _log(self, log_level: str, message: str, **context: t.LogValue) -> None: match log_level: case c.LogLevel.DEBUG: @@ -126,10 +82,7 @@ def _log(self, log_level: str, message: str, **context: t.LogValue) -> None: self.logger.info(message, **context) def _print_message( - self, - message: str, - log_level: str, - message_format: str, + self, message: str, log_level: str, message_format: str ) -> p.Result[bool]: # Fail loud: a logger failure propagates with its cause. self._log(log_level, message_format.format(message=message)) diff --git a/src/flext_cli/services/prompts.py b/src/flext_cli/services/prompts.py index 3a093b4f..d072cd3f 100644 --- a/src/flext_cli/services/prompts.py +++ b/src/flext_cli/services/prompts.py @@ -8,12 +8,6 @@ from ._prompts_support import FlextCliPromptsSupport -# NOTE (multi-agent): mro-i6nq.13 — consolidated _prompts_parts/part_01+part_02 -# (and the empty part_03 + facade pass-through layers) into this single cohesive -# module. Repeated try/except CLI_SAFE_EXCEPTIONS -> _fatal -> r.fail idioms are -# collapsed onto the support base ``_guarded`` (u.guard_result). Public surface -# and error messages unchanged. - class FlextCliPrompts(FlextCliPromptsSupport): """Interactive CLI prompt surface exposed through the CLI service runtime.""" @@ -30,38 +24,23 @@ def confirm(self, message: str, *, default: bool = False) -> p.Result[bool]: return r[bool].ok(default) prompt_text = u.Cli.prompts_confirmation_text(message, default=default) return self._read_confirmation_input(message, prompt_text, default=default) - except KeyboardInterrupt: - return r[bool].fail(c.Cli.ERR_USER_CANCELLED_CONFIRMATION) - except EOFError: - return r[bool].fail(c.Cli.ERR_INPUT_STREAM_ENDED) - except c.Cli.CLI_SAFE_EXCEPTIONS as exc: - self._fatal("confirm", message, exc, "Confirmation failed completely") - return r[bool].fail(c.Cli.ERR_CONFIRMATION_FAILED_FMT.format(error=exc)) + except KeyboardInterrupt as exc: + return r[bool].fail(c.Cli.ERR_USER_CANCELLED_CONFIRMATION, exception=exc) + except EOFError as exc: + return r[bool].fail(c.Cli.ERR_INPUT_STREAM_ENDED, exception=exc) def prompt(self, message: str, default: str = "") -> p.Result[str]: """Read one text value or return the configured default.""" if self.state.quiet or not self.state.interactive: return r[str].ok(default) - return self._guarded( - "prompt", - message, - lambda: r[str].ok(self._read_prompt_value(message, default)), - consequence="Prompt failed completely", - error_format=c.Cli.ERR_PROMPT_FAILED_FMT, - ) + return r[str].ok(self._read_prompt_value(message, default)) def prompt_choice( - self, message: str, choices: t.StrSequence, default: str | None = None + self, choices: t.StrSequence, default: str | None = None ) -> p.Result[str]: - """Read one value constrained to the supplied choices.""" - return self._guarded( - "prompt_choice", - message, - lambda: u.Cli.prompts_choice_result( - interactive=self.state.interactive, choices=choices, default=default - ), - consequence="Choice prompt failed completely", - error_format=c.Cli.ERR_CHOICE_PROMPT_FAILED_FMT, + """Resolve one value constrained to the supplied choices.""" + return u.Cli.prompts_choice_result( + interactive=self.state.interactive, choices=choices, default=default ) def prompt_password( @@ -72,39 +51,23 @@ def prompt_password( """Read a password and enforce the minimum length.""" if not self.state.interactive: return r[str].fail(c.Cli.ERR_INTERACTIVE_PASSWORD_DISABLED) - return self._guarded( - "prompt_password", - message, - lambda: u.Cli.prompts_password_result( - self.password_reader(f"{message}{c.Cli.PROMPT_SPACE}"), - min_length=min_length, - ), - consequence="Password prompt failed completely", - error_format=c.Cli.ERR_PASSWORD_PROMPT_FAILED_FMT, + return u.Cli.prompts_password_result( + self.password_reader(f"{message}{c.Cli.PROMPT_SPACE}"), + min_length=min_length, ) def print_error(self, message: str) -> p.Result[bool]: """Render an error message through the canonical prompt output path.""" - return self._print_message( - message, - c.LogLevel.ERROR, - c.Cli.PROMPT_ERROR_FMT, - ) + return self._print_message(message, c.LogLevel.ERROR, c.Cli.PROMPT_ERROR_FMT) def print_success(self, message: str) -> p.Result[bool]: """Render a success message through the canonical prompt output path.""" - return self._print_message( - message, - c.LogLevel.INFO, - c.Cli.PROMPT_SUCCESS_FMT, - ) + return self._print_message(message, c.LogLevel.INFO, c.Cli.PROMPT_SUCCESS_FMT) def print_warning(self, message: str) -> p.Result[bool]: """Render a warning message through the canonical prompt output path.""" return self._print_message( - message, - c.LogLevel.WARNING, - c.Cli.PROMPT_WARNING_FMT, + message, c.LogLevel.WARNING, c.Cli.PROMPT_WARNING_FMT ) def _read_prompt_value(self, message: str, default: str) -> str: diff --git a/tests/protocols.py b/tests/protocols.py index f2c4b83f..9a9b11fc 100644 --- a/tests/protocols.py +++ b/tests/protocols.py @@ -38,7 +38,7 @@ def confirm(self, message: str, *, default: bool = False) -> p.Result[bool]: ... def prompt_choice( - self, message: str, choices: t.StrSequence, default: str | None = None + self, choices: t.StrSequence, default: str | None = None ) -> p.Result[str]: """Define the prompt choice test contract.""" ... diff --git a/tests/unit/_cases/test_prompts/testsflextcliprompts_part_01.py b/tests/unit/_cases/test_prompts/testsflextcliprompts_part_01.py index 3972ce55..929d90b4 100644 --- a/tests/unit/_cases/test_prompts/testsflextcliprompts_part_01.py +++ b/tests/unit/_cases/test_prompts/testsflextcliprompts_part_01.py @@ -54,14 +54,6 @@ def test_prompt_reads_input_and_uses_default_for_empty_text( tm.ok(default_result) tm.that(default_result.value, eq="default") - def test_prompt_handles_input_failure( - self, make_prompts: Callable[..., p.Tests.Prompts] - ) -> None: - """Verify that prompt handles input failure.""" - prompts = make_prompts(error=ValueError("Input error")) - result = prompts.prompt("Enter value") - tm.fail(result, has="Input error") - def test_confirm_returns_defaults_when_not_interactive( self, make_prompts: Callable[..., p.Tests.Prompts] ) -> None: @@ -90,7 +82,6 @@ def test_confirm_accepts_yes_no_default_and_invalid_retry( [ (KeyboardInterrupt(), c.Cli.ERR_USER_CANCELLED_CONFIRMATION), (EOFError(), c.Cli.ERR_INPUT_STREAM_ENDED), - (ValueError("Test error"), "Test error"), ], ) def test_confirm_handles_failures( @@ -122,10 +113,6 @@ def test_prompt_password_paths( valid_result = valid_prompts.prompt_password("Password:", min_length=8) tm.ok(valid_result) tm.that(valid_result.value, eq=valid_secret) - failing_prompts = make_prompts(error=ValueError("Password input error")) - tm.fail( - failing_prompts.prompt_password("Password:"), has="Password input error" - ) __all__: list[str] = ["TestsFlextCliPrompts"] diff --git a/tests/unit/_cases/test_prompts/testsflextcliprompts_part_02.py b/tests/unit/_cases/test_prompts/testsflextcliprompts_part_02.py index 4a654ad7..59109163 100644 --- a/tests/unit/_cases/test_prompts/testsflextcliprompts_part_02.py +++ b/tests/unit/_cases/test_prompts/testsflextcliprompts_part_02.py @@ -25,32 +25,28 @@ def test_prompt_choice_paths( """Verify that prompt choice paths.""" quiet_prompts = make_prompts(interactive_mode=False) tm.fail( - quiet_prompts.prompt_choice("Select:", choices=[], default=None), + quiet_prompts.prompt_choice(choices=[], default=None), has=c.Cli.ERR_NO_CHOICES, ) tm.fail( - quiet_prompts.prompt_choice("Select:", choices=["a", "b"], default=None), + quiet_prompts.prompt_choice(choices=["a", "b"], default=None), has=c.Cli.ERR_INTERACTIVE_CHOICE_DISABLED, ) - valid_default = quiet_prompts.prompt_choice( - "Select:", choices=["a", "b"], default="a" - ) + valid_default = quiet_prompts.prompt_choice(choices=["a", "b"], default="a") tm.ok(valid_default) tm.that(valid_default.value, eq="a") interactive_prompts = make_prompts() required = interactive_prompts.prompt_choice( - "Select:", choices=["alpha", "beta"], default=None + choices=["alpha", "beta"], default=None ) tm.fail(required, has="alpha") tm.fail(required, has="beta") tm.fail( - interactive_prompts.prompt_choice( - "Select:", choices=["a", "b"], default="c" - ), + interactive_prompts.prompt_choice(choices=["a", "b"], default="c"), has=c.Cli.ERR_INVALID_CHOICE_FMT.format(choice="c"), ) selected = interactive_prompts.prompt_choice( - "Select:", choices=["simple", "complex", "advanced"], default="simple" + choices=["simple", "complex", "advanced"], default="simple" ) tm.ok(selected) tm.that(selected.value, eq="simple") diff --git a/tests/unit/test_prompts_cov.py b/tests/unit/test_prompts_cov.py index bdcd8943..ba3a42e7 100644 --- a/tests/unit/test_prompts_cov.py +++ b/tests/unit/test_prompts_cov.py @@ -56,13 +56,13 @@ def test_prompt_returns_default_when_non_interactive( tm.ok(result) tm.that(result.value, eq="fallback") - def test_prompt_fails_when_input_reader_raises( + def test_prompt_propagates_input_reader_failure( self, make_prompts: Callable[..., p.Tests.Prompts] ) -> None: - """Verify that prompt fails when input reader raises.""" + """An input-port failure escapes ``prompt`` with its original cause.""" prompts = make_prompts(error=ValueError("boom")) - result = prompts.prompt("message", default="default") - tm.fail(result, has="boom") + with pytest.raises(ValueError, match="boom"): + prompts.prompt("message", default="default") @pytest.mark.parametrize( ("answer", "default", "expected"), @@ -113,26 +113,34 @@ def test_confirm_returns_default_when_non_interactive( [ (KeyboardInterrupt(), c.Cli.ERR_USER_CANCELLED_CONFIRMATION), (EOFError(), c.Cli.ERR_INPUT_STREAM_ENDED), - (ValueError("bad"), "bad"), ], ) - def test_confirm_fails_on_input_errors( + def test_confirm_fails_on_cancellation_carrying_its_cause( self, make_prompts: Callable[..., p.Tests.Prompts], - error: Exception, + error: BaseException, expected: str, ) -> None: - """Verify that confirm fails on input errors.""" + """Cancellation and end of input are declared outcomes with their cause.""" prompts = make_prompts(error=error) result = prompts.confirm("message", default=False) tm.fail(result, has=expected) + tm.that(result.exception is error, eq=True) + + def test_confirm_propagates_input_reader_failure( + self, make_prompts: Callable[..., p.Tests.Prompts] + ) -> None: + """Any other input-port failure escapes ``confirm`` unchanged.""" + prompts = make_prompts(error=ValueError("bad")) + with pytest.raises(ValueError, match="bad"): + prompts.confirm("message", default=False) def test_prompt_choice_returns_default_when_present( self, make_prompts: Callable[..., p.Tests.Prompts] ) -> None: """Verify that prompt choice returns default when present.""" prompts = make_prompts() - result = prompts.prompt_choice("Choose", choices=("a", "b"), default="a") + result = prompts.prompt_choice(choices=("a", "b"), default="a") tm.ok(result) tm.that(result.value, eq="a") @@ -141,7 +149,7 @@ def test_prompt_choice_fails_with_empty_choices( ) -> None: """Verify that prompt choice fails with empty choices.""" prompts = make_prompts() - result = prompts.prompt_choice("Choose", choices=(), default=None) + result = prompts.prompt_choice(choices=(), default=None) tm.fail(result, has=c.Cli.ERR_NO_CHOICES) def test_prompt_choice_fails_when_default_not_in_choices( @@ -149,7 +157,7 @@ def test_prompt_choice_fails_when_default_not_in_choices( ) -> None: """Verify that prompt choice fails when default not in choices.""" prompts = make_prompts() - result = prompts.prompt_choice("Choose", choices=("a", "b"), default="z") + result = prompts.prompt_choice(choices=("a", "b"), default="z") tm.fail(result, has="z") def test_prompt_choice_fails_when_default_required( @@ -157,7 +165,7 @@ def test_prompt_choice_fails_when_default_required( ) -> None: """Verify that prompt choice fails when default required.""" prompts = make_prompts() - result = prompts.prompt_choice("Choose", choices=("a", "b"), default=None) + result = prompts.prompt_choice(choices=("a", "b"), default=None) tm.fail(result) def test_prompt_password_returns_value_meeting_min_length( @@ -191,13 +199,13 @@ def test_prompt_password_fails_when_non_interactive( result = prompts.prompt_password("Password:") tm.fail(result, has=c.Cli.ERR_INTERACTIVE_PASSWORD_DISABLED) - def test_prompt_password_fails_when_reader_raises( + def test_prompt_password_propagates_reader_failure( self, make_prompts: Callable[..., p.Tests.Prompts] ) -> None: - """Verify that prompt password fails when reader raises.""" + """A password-port failure escapes ``prompt_password`` unchanged.""" prompts = make_prompts(error=ValueError("no tty")) - result = prompts.prompt_password("Password:") - tm.fail(result, has="no tty") + with pytest.raises(ValueError, match="no tty"): + prompts.prompt_password("Password:") __all__: list[str] = ["TestsFlextCliPromptsCov"] From 92d9a37dbbbca0237291ec17e2bb657766bf305c Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Sat, 26 Sep 2026 09:40:35 -0300 Subject: [PATCH 3/4] fix(options): fail loud on a field default with no CLI form u.Cli.field_default no longer catches the default-source validation error and returns None. None stays the typed absence of a default; any other unrepresentable default now fails cli.model_command at build: the validation error escapes unchanged, and a validated default no Typer option carries raises TypeError naming the field. Structured defaults keep the JSON-option path. Drop the prompt failure-format constants left unused by the prompt fail-loud change. make fmt normalized two option test files. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/flext_cli/_constants/errors.py | 7 ++-- .../flextcliutilitiesoptions_part_02.py | 41 +++++++++---------- tests/unit/test_model_command_json_options.py | 4 +- tests/unit/test_options_cov.py | 29 +++++++++++++ tests/unit/test_options_public_cov.py | 4 +- 5 files changed, 56 insertions(+), 29 deletions(-) diff --git a/src/flext_cli/_constants/errors.py b/src/flext_cli/_constants/errors.py index dde0d6cb..b488330c 100644 --- a/src/flext_cli/_constants/errors.py +++ b/src/flext_cli/_constants/errors.py @@ -94,7 +94,6 @@ class FlextCliConstantsErrors: ) ERR_USER_CANCELLED_CONFIRMATION: ClassVar[str] = "User cancelled confirmation" ERR_INPUT_STREAM_ENDED: ClassVar[str] = "Input stream ended" - ERR_CONFIRMATION_FAILED_FMT: ClassVar[str] = "Confirmation failed: {error}" ERR_NO_CHOICES: ClassVar[str] = "No choices provided" ERR_INTERACTIVE_CHOICE_DISABLED: ClassVar[str] = ( "Interactive mode disabled for choice prompt" @@ -107,9 +106,6 @@ class FlextCliConstantsErrors: ERR_PASSWORD_TOO_SHORT_FMT: ClassVar[str] = ( "Password too short: minimum {min_length} characters" ) - ERR_PROMPT_FAILED_FMT: ClassVar[str] = "Prompt failed: {error}" - ERR_CHOICE_PROMPT_FAILED_FMT: ClassVar[str] = "Choice prompt failed: {error}" - ERR_PASSWORD_PROMPT_FAILED_FMT: ClassVar[str] = "Password prompt failed: {error}" ERR_INVALID_COMMAND_NAME: ClassVar[str] = "Invalid command name" ERR_COMMAND_FAILED: ClassVar[str] = "Command failed" @@ -123,6 +119,9 @@ class FlextCliConstantsErrors: ERR_CLI_DEFINITION_FIELD: ClassVar[str] = ( "command '{command}' model '{model}' field '{field}': {reason}" ) + ERR_FIELD_DEFAULT_NOT_CLI_VALUE_FMT: ClassVar[str] = ( + "field '{field_name}' default {value!r} has no CLI option form" + ) __all__: t.MutableSequenceOf[str] = ["FlextCliConstantsErrors"] diff --git a/src/flext_cli/_utilities/_options_parts/flextcliutilitiesoptions_part_02.py b/src/flext_cli/_utilities/_options_parts/flextcliutilitiesoptions_part_02.py index 5c564991..8e6141fa 100644 --- a/src/flext_cli/_utilities/_options_parts/flextcliutilitiesoptions_part_02.py +++ b/src/flext_cli/_utilities/_options_parts/flextcliutilitiesoptions_part_02.py @@ -19,7 +19,14 @@ class FlextCliUtilitiesOptions(FlextCliUtilitiesOptionsPart01): def field_default( cls, field_name: str, field_info: m.FieldInfo, settings: t.Cli.ModelLike | None ) -> t.Cli.CliValue | None: - """Resolve CLI default from settings first, then from model field metadata.""" + """Resolve CLI default from settings first, then from model field metadata. + + ``None`` is the typed absence of a default. Any other default without a + CLI form fails at command build with its cause: the default-source + validation error escapes unchanged, and a validated default that no + Typer option carries raises ``TypeError``. Structured defaults travel + through the JSON-option path. + """ default_factory = getattr(field_info, "default_factory", None) source_value = ( getattr(settings, field_name) @@ -28,29 +35,21 @@ def field_default( if callable(default_factory) else getattr(field_info, "default", None) ) + if source_value is None: + return None if cls.is_json_option(getattr(field_info, "annotation", None) or str): # A JSON option's default is the JSON text its parser validates. - return None if source_value is None else u.to_json(source_value).decode() - try: - normalized_source = t.Cli.CLI_DEFAULT_SOURCE_ADAPTER.validate_python( - source_value - ) - except c.EXC_VALIDATION_TYPE_VALUE: - normalized_source = None - if normalized_source is None: - return None - match normalized_source: - case _ if ( - normalized_atom := cls.normalize_cli_atom(normalized_source) - ) is not None: - normalized_default: t.Cli.CliValue | None = normalized_atom - case _ if cls.is_string_sequence(normalized_source): - normalized_default = t.Cli.STR_SEQUENCE_ADAPTER.validate_python( - normalized_source + return u.to_json(source_value).decode() + normalized_atom = cls.normalize_cli_atom( + t.Cli.CLI_DEFAULT_SOURCE_ADAPTER.validate_python(source_value) + ) + if normalized_atom is None: + raise TypeError( + c.Cli.ERR_FIELD_DEFAULT_NOT_CLI_VALUE_FMT.format( + field_name=field_name, value=source_value ) - case _: - normalized_default = None - return normalized_default + ) + return normalized_atom @staticmethod def build_option( diff --git a/tests/unit/test_model_command_json_options.py b/tests/unit/test_model_command_json_options.py index aeb432d6..e38c3338 100644 --- a/tests/unit/test_model_command_json_options.py +++ b/tests/unit/test_model_command_json_options.py @@ -140,7 +140,9 @@ def test_invalid_json_option_fails_loud_with_cause( executed: list[TestsFlextCliModelCommandJsonOptions.MappingModel] = [] app = self._app(self.MappingModel, executed) - result = cli.execute_app(app, prog_name="json-app", args=["run", "--labels", raw]) + result = cli.execute_app( + app, prog_name="json-app", args=["run", "--labels", raw] + ) tm.fail(result, has=error_type) tm.that(executed, empty=True) diff --git a/tests/unit/test_options_cov.py b/tests/unit/test_options_cov.py index 967a7a59..a2148016 100644 --- a/tests/unit/test_options_cov.py +++ b/tests/unit/test_options_cov.py @@ -106,6 +106,21 @@ class OptionsDefaultsModel(m.BaseModel): tags: t.StrSequence = ("a", "b") generated: t.StrSequence = m.Field(("gen", "value"), validate_default=True) + class IntSequenceDefaultModel(m.BaseModel): + """Model whose validated default has no CLI option form.""" + + counts: t.SequenceOf[int] = (1, 2) + + class NestedListSettings(m.BaseModel): + """Settings whose value is not a CLI default source at all.""" + + value: t.SequenceOf[t.SequenceOf[int]] = ((1,),) + + class StrSequenceDefaultModel(m.BaseModel): + """Model seeded by ``NestedListSettings``.""" + + value: t.StrSequence = ("a",) + _INVOCATION_CASES: ClassVar[ t.VariadicTuple[t.Pair[t.StrSequence, t.Cli.ModelLike]] ] = ( @@ -195,6 +210,20 @@ def test_model_command_rejects_missing_required_option(self) -> None: tm.that(u.Cli.process_succeeded(invocation.outcome), eq=False) tm.that(received, empty=True) + def test_model_command_rejects_default_without_cli_form_at_build(self) -> None: + """A validated default no option can carry fails the build, naming it.""" + with pytest.raises(TypeError, match="counts"): + cli.model_command(self.IntSequenceDefaultModel, self._noop_handler) + + def test_model_command_propagates_invalid_default_source_at_build(self) -> None: + """A settings value outside the default-source contract escapes unchanged.""" + with pytest.raises(m.ValidationError): + cli.model_command( + self.StrSequenceDefaultModel, + self._noop_handler, + settings=self.NestedListSettings(), + ) + def test_field_default_prefers_settings_value_over_model_default(self) -> None: """An omitted option takes the value of the supplied settings model.""" settings = self.OptionsDefaultsModel(name="override-name") diff --git a/tests/unit/test_options_public_cov.py b/tests/unit/test_options_public_cov.py index a266c463..39d7eac8 100644 --- a/tests/unit/test_options_public_cov.py +++ b/tests/unit/test_options_public_cov.py @@ -110,9 +110,7 @@ def test_field_default_renders_mapping_field_as_json_text(self) -> None: default = u.Cli.field_default("flags", fields["flags"], settings) tm.that(isinstance(default, str), eq=True) - tm.that( - t.Cli.JSON_VALUE_ADAPTER.validate_json(str(default)), eq=settings.flags - ) + tm.that(t.Cli.JSON_VALUE_ADAPTER.validate_json(str(default)), eq=settings.flags) def test_field_default_falls_back_to_field_metadata_without_settings(self) -> None: """Verify that field default falls back to field metadata without settings.""" From 0999f31bf3045b2c4266aa461cbb266e6b3656f7 Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Sat, 26 Sep 2026 09:49:17 -0300 Subject: [PATCH 4/4] test(options): isolate the single raising call in the invalid-default test Build the settings model before pytest.raises so the block holds exactly one invocation that can raise (SonarCloud python:S5778 on PR #202). Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/unit/test_options_cov.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_options_cov.py b/tests/unit/test_options_cov.py index a2148016..80b4ffbb 100644 --- a/tests/unit/test_options_cov.py +++ b/tests/unit/test_options_cov.py @@ -217,11 +217,10 @@ def test_model_command_rejects_default_without_cli_form_at_build(self) -> None: def test_model_command_propagates_invalid_default_source_at_build(self) -> None: """A settings value outside the default-source contract escapes unchanged.""" + settings = self.NestedListSettings() with pytest.raises(m.ValidationError): cli.model_command( - self.StrSequenceDefaultModel, - self._noop_handler, - settings=self.NestedListSettings(), + self.StrSequenceDefaultModel, self._noop_handler, settings=settings ) def test_field_default_prefers_settings_value_over_model_default(self) -> None: