From 2c158132413bf7741c28553119bf326cb8a1e2ca Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Sat, 26 Sep 2026 15:30:35 -0300 Subject: [PATCH 1/6] [WIP] feat(cli): service_routes derived from service operations (adopted checkpoint) Checkpoint of the interrupted S5 lane before review against R26-R32. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/flext_cli/_constants/errors.py | 7 ++ .../_base/flextclimodelsbase_part_03.py | 5 + src/flext_cli/_typings/domain.py | 2 - src/flext_cli/_utilities/__init__.py | 3 - src/flext_cli/_utilities/_cli_namespace.py | 2 - .../flextcliutilitiesoptions_part_02.py | 21 +++- src/flext_cli/_utilities/model_commands.py | 36 ------ src/flext_cli/services/_cli_parts/__init__.py | 4 +- .../_cli_parts/flextclicli_part_01.py | 30 ++++- .../_cli_parts/flextclicli_part_02.py | 8 +- .../_cli_parts/flextclicli_part_03.py | 55 +++++---- .../_cli_parts/flextclicli_part_05.py | 17 ++- .../_cli_parts/flextclicli_part_06.py | 65 ++++++++++ src/flext_cli/services/cli.py | 4 +- tests/_models_parts/tests_runtime.py | 8 +- .../testsflextcliservice_part_03.py | 29 +---- ...flextclipubliccontractscoverage_part_01.py | 35 +----- tests/unit/test_model_commands_cov.py | 67 +--------- tests/unit/test_service_routes.py | 116 ++++++++++++++++++ uv.lock | 8 +- 20 files changed, 290 insertions(+), 232 deletions(-) delete mode 100644 src/flext_cli/_utilities/model_commands.py create mode 100644 src/flext_cli/services/_cli_parts/flextclicli_part_06.py create mode 100644 tests/unit/test_service_routes.py diff --git a/src/flext_cli/_constants/errors.py b/src/flext_cli/_constants/errors.py index e6853cefc..7d149c1fd 100644 --- a/src/flext_cli/_constants/errors.py +++ b/src/flext_cli/_constants/errors.py @@ -119,6 +119,13 @@ class FlextCliConstantsErrors: ERR_FIELD_DEFAULT_NOT_CLI_VALUE_FMT: ClassVar[str] = ( "field '{field_name}' default {value!r} has no CLI option form" ) + ERR_FIELD_WITHOUT_ANNOTATION_FMT: ClassVar[str] = ( + "field '{field_name}' declares no annotation, so it has no CLI option form" + ) + ERR_REQUIRED_EXCLUDED_FIELD_FMT: ClassVar[str] = ( + "model '{model}' field '{field_name}' is required and exclude=True, " + "so no CLI option can supply it; give it a default or expose it" + ) __all__: t.MutableSequenceOf[str] = ["FlextCliConstantsErrors"] diff --git a/src/flext_cli/_models/_base/flextclimodelsbase_part_03.py b/src/flext_cli/_models/_base/flextclimodelsbase_part_03.py index 8f7c8a354..270220baf 100644 --- a/src/flext_cli/_models/_base/flextclimodelsbase_part_03.py +++ b/src/flext_cli/_models/_base/flextclimodelsbase_part_03.py @@ -105,6 +105,11 @@ class CommandEntryModel(m.BaseModel): t.Cli.JsonCommandFn, m.Field(..., description="Command handler callable") ] + class EmptyRequest(m.BaseModel): + """Shared request model of a service operation that takes no input.""" + + model_config: ClassVar[m.ConfigDict] = m.ConfigDict(extra="forbid", frozen=True) + class ResultCommandRoute(m.BaseModel): """Type-erased route contract for heterogeneous batch registration.""" diff --git a/src/flext_cli/_typings/domain.py b/src/flext_cli/_typings/domain.py index b5c037d92..0b8f2baaa 100644 --- a/src/flext_cli/_typings/domain.py +++ b/src/flext_cli/_typings/domain.py @@ -57,8 +57,6 @@ class FlextCliTypesDomain: type IntTextValue = int | str type MessageType = c.Cli.MessageTypes type ModelLike = t.BaseModel - # mro-j47u (codex): model classes use the canonical core type alias. - type ModelSource = ModelLike | t.JsonMapping | t.ScalarMapping type OptionRegistry = t.MappingKV[str, t.MappingKV[str, t.Scalar | t.StrSequence]] type NullaryOperation[T] = Callable[[], T] type PromptTextReader = Callable[[str], str] diff --git a/src/flext_cli/_utilities/__init__.py b/src/flext_cli/_utilities/__init__.py index 092e9b017..7eb74b340 100644 --- a/src/flext_cli/_utilities/__init__.py +++ b/src/flext_cli/_utilities/__init__.py @@ -216,7 +216,6 @@ from .framework import FlextCliUtilitiesFramework from .json import FlextCliUtilitiesJson from .matching import FlextCliUtilitiesMatching - from .model_commands import FlextCliUtilitiesModelCommands from .output import FlextCliUtilitiesOutput from .params import FlextCliUtilitiesParams from .pipeline import FlextCliUtilitiesPipeline @@ -258,7 +257,6 @@ "FlextCliUtilitiesJsonCoreMixin", "FlextCliUtilitiesJsonNavigateMixin", "FlextCliUtilitiesMatching", - "FlextCliUtilitiesModelCommands", "FlextCliUtilitiesOptionBuilder", "FlextCliUtilitiesOptions", "FlextCliUtilitiesOutput", @@ -648,7 +646,6 @@ ".framework": ("FlextCliUtilitiesFramework",), ".json": ("FlextCliUtilitiesJson",), ".matching": ("FlextCliUtilitiesMatching",), - ".model_commands": ("FlextCliUtilitiesModelCommands",), ".output": ("FlextCliUtilitiesOutput",), ".params": ("FlextCliUtilitiesParams",), ".pipeline": ("FlextCliUtilitiesPipeline",), diff --git a/src/flext_cli/_utilities/_cli_namespace.py b/src/flext_cli/_utilities/_cli_namespace.py index 53dd7fe07..d58b4bf96 100644 --- a/src/flext_cli/_utilities/_cli_namespace.py +++ b/src/flext_cli/_utilities/_cli_namespace.py @@ -16,7 +16,6 @@ from .framework import FlextCliUtilitiesFramework from .json import FlextCliUtilitiesJson from .matching import FlextCliUtilitiesMatching -from .model_commands import FlextCliUtilitiesModelCommands from .output import FlextCliUtilitiesOutput from .params import FlextCliUtilitiesParams from .pipeline import FlextCliUtilitiesPipeline @@ -49,7 +48,6 @@ class FlextCliUtilitiesCli( FlextCliUtilitiesFramework, FlextCliUtilitiesJson, FlextCliUtilitiesMatching, - FlextCliUtilitiesModelCommands, FlextCliUtilitiesOptions, FlextCliUtilitiesOutput, FlextCliUtilitiesParams, 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 8e6141fa4..db44a065b 100644 --- a/src/flext_cli/_utilities/_options_parts/flextcliutilitiesoptions_part_02.py +++ b/src/flext_cli/_utilities/_options_parts/flextcliutilitiesoptions_part_02.py @@ -15,6 +15,18 @@ class FlextCliUtilitiesOptions(FlextCliUtilitiesOptionsPart01): """Implementation part for FlextCliUtilitiesOptions.""" + @staticmethod + def field_annotation( + field_name: str, field_info: m.FieldInfo + ) -> t.Cli.RuntimeAnnotation: + """Return the declared annotation of a CLI field or fail naming the field.""" + annotation = field_info.annotation + if annotation is None: + raise TypeError( + c.Cli.ERR_FIELD_WITHOUT_ANNOTATION_FMT.format(field_name=field_name) + ) + return annotation + @classmethod def field_default( cls, field_name: str, field_info: m.FieldInfo, settings: t.Cli.ModelLike | None @@ -27,17 +39,14 @@ def field_default( 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) - if settings is not None and hasattr(settings, field_name) - else default_factory() - if callable(default_factory) - else getattr(field_info, "default", None) + if settings is not None and field_name in type(settings).model_fields + else field_info.get_default(call_default_factory=True, validated_data={}) ) if source_value is None: return None - if cls.is_json_option(getattr(field_info, "annotation", None) or str): + if cls.is_json_option(cls.field_annotation(field_name, field_info)): # A JSON option's default is the JSON text its parser validates. return u.to_json(source_value).decode() normalized_atom = cls.normalize_cli_atom( diff --git a/src/flext_cli/_utilities/model_commands.py b/src/flext_cli/_utilities/model_commands.py deleted file mode 100644 index a67f674a7..000000000 --- a/src/flext_cli/_utilities/model_commands.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Model-source helpers shared through ``u.Cli``. - -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 - -from collections.abc import Mapping - -from flext_cli import t - - -class FlextCliUtilitiesModelCommands: - """Model-source methods exposed directly on ``u.Cli``.""" - - @staticmethod - def model_source_data( - model_cls: t.ModelClass[t.Cli.ModelLike], source: t.Cli.ModelSource - ) -> t.JsonMapping: - """Extract only target-compatible fields from a model or mapping source.""" - raw_source: t.JsonMapping | t.ScalarMapping - if isinstance(source, Mapping): - raw_source = source - else: - raw_source = source.model_dump(exclude_none=True) - filtered_payload = { - field_name: raw_source[field_name] - for field_name in model_cls.model_fields - if field_name in raw_source and raw_source[field_name] is not None - } - return t.Cli.JSON_MAPPING_ADAPTER.validate_python(filtered_payload) - - -__all__: list[str] = ["FlextCliUtilitiesModelCommands"] diff --git a/src/flext_cli/services/_cli_parts/__init__.py b/src/flext_cli/services/_cli_parts/__init__.py index 6044bfa7c..279d44aee 100644 --- a/src/flext_cli/services/_cli_parts/__init__.py +++ b/src/flext_cli/services/_cli_parts/__init__.py @@ -9,14 +9,14 @@ from flext_core.lazy import build_lazy_import_map, install_lazy_exports if TYPE_CHECKING: - from .flextclicli_part_05 import FlextCliCli + from .flextclicli_part_06 import FlextCliCli __all__: tuple[str, ...] = ("FlextCliCli",) _LAZY_IMPORTS = MappingProxyType( build_lazy_import_map( - MappingProxyType({".flextclicli_part_05": ("FlextCliCli",)}), + MappingProxyType({".flextclicli_part_06": ("FlextCliCli",)}), alias_groups=MappingProxyType({}), sort_keys=False, ) diff --git a/src/flext_cli/services/_cli_parts/flextclicli_part_01.py b/src/flext_cli/services/_cli_parts/flextclicli_part_01.py index 2cf807aaf..341447221 100644 --- a/src/flext_cli/services/_cli_parts/flextclicli_part_01.py +++ b/src/flext_cli/services/_cli_parts/flextclicli_part_01.py @@ -9,8 +9,9 @@ from collections.abc import Mapping, Sequence from inspect import Parameter, Signature from types import GenericAlias +from typing import Never -from flext_cli import c, m, p, t, u +from flext_cli import c, e, m, p, r, settings, t, u class FlextCliCli: @@ -28,6 +29,7 @@ class _ModelCommand[M: t.Cli.ModelLike]: __signature__: Signature _handler: p.Cli.ModelCommandHandler[M] _model_cls: t.ModelClass[M] + _result_border: bool def __init__( self, @@ -35,18 +37,38 @@ def __init__( handler: p.Cli.ModelCommandHandler[M], model_cls: t.ModelClass[M], parameters: t.SequenceOf[Parameter], + result_border: bool, ) -> None: self.__name__ = getattr(handler, "__name__", model_cls.__name__) self.__signature__ = Signature(parameters) self._handler = handler self._model_cls = model_cls + self._result_border = result_border def __call__(self, **kwargs: t.Cli.CliValue) -> t.JsonValue: # Typer passes each option under its parameter (field) name, so an - # aliased field must validate by name as well as by alias. - model = self._model_cls.model_validate(kwargs, by_name=True) + # aliased field must validate by name as well as by alias. A result + # border turns rejected input into ``e.fail_validation`` carrying + # the ValidationError and exits non-zero; a plain command raises it. + try: + model = self._model_cls.model_validate(kwargs, by_name=True) + except m.ValidationError as exc: + if not self._result_border: + raise + FlextCliCli._exit_failure( + e.fail_validation( + self._model_cls.__name__, error=exc, result_type=r[bool] + ) + ) return self._handler(model) + @staticmethod + def _exit_failure[TResult: t.Cli.ResultValue](result: p.Result[TResult]) -> Never: + """Expose a failed Result once at the CLI border and exit non-zero.""" + u.Cli.framework_exit_result(result) + u.Cli.commands_emit_result_error(result, verbose=settings.cli_verbose) + u.Cli.framework_exit(c.Cli.EXIT_CODE_FAILURE) + @classmethod def _build_model_parameter( cls, field_name: str, field_info: m.FieldInfo, settings: t.Cli.ModelLike | None @@ -69,7 +91,7 @@ def _build_model_parameter( candidate = f"--{choice.replace('_', '-')}" if candidate != option_name and candidate not in extra_option_names: extra_option_names.append(candidate) - field_annotation = getattr(field_info, "annotation", None) or str + field_annotation = u.Cli.field_annotation(field_name, field_info) annotation = u.Cli.resolve_typer_annotation(field_annotation) json_annotation = ( field_annotation if u.Cli.is_json_option(field_annotation) else None diff --git a/src/flext_cli/services/_cli_parts/flextclicli_part_02.py b/src/flext_cli/services/_cli_parts/flextclicli_part_02.py index be8662158..7d8cd34ee 100644 --- a/src/flext_cli/services/_cli_parts/flextclicli_part_02.py +++ b/src/flext_cli/services/_cli_parts/flextclicli_part_02.py @@ -25,11 +25,8 @@ def _apply_common_params_to_config(self, *, params: m.Cli.CliParamsConfig) -> No next_params = params.model_copy(update={"log_level": resolved_log_level}) result = FlextCliCommonParams.apply_to_config(settings, params=next_params) if result.failure: - u.fetch_logger(__name__).warning( - "failed to apply cli params", error=result.error or "" - ) - else: - self._apply_updated_settings(result.value) + self._exit_failure(result) + self._apply_updated_settings(result.value) @staticmethod def _apply_updated_settings(updated_settings: p.Cli.Settings) -> None: @@ -86,6 +83,7 @@ def apply_common_params(params: m.Cli.CliParamsConfig) -> bool: handler=apply_common_params, model_cls=m.Cli.CliParamsConfig, parameters=parameters, + result_border=True, ) ) global_callback.__annotations__ = dict(annotations) diff --git a/src/flext_cli/services/_cli_parts/flextclicli_part_03.py b/src/flext_cli/services/_cli_parts/flextclicli_part_03.py index e8827c40f..836b23d28 100644 --- a/src/flext_cli/services/_cli_parts/flextclicli_part_03.py +++ b/src/flext_cli/services/_cli_parts/flextclicli_part_03.py @@ -28,13 +28,27 @@ def model_command[M: t.Cli.ModelLike]( model_cls: t.ModelClass[M], handler: p.Cli.ModelCommandHandler[M], settings: t.Cli.ModelLike | None = None, + *, + result_border: bool = False, ) -> t.Cli.CliCommand: - """Build a Typer command directly from a Pydantic request model.""" + """Build a Typer command directly from a Pydantic request model. + + A field marked ``exclude=True`` gets no option, so it must carry a + default: a required excluded field raises ``TypeError`` at build time. + ``result_border`` turns rejected input into ``e.fail_validation`` with a + non-zero exit instead of raising the ``ValidationError``. + """ parameters: t.MutableSequenceOf[Parameter] = [] annotations: t.Cli.CliAnnotations = {"return": type(None)} fields = model_cls.model_fields for field_name, field_info in fields.items(): - if getattr(field_info, "exclude", None) is True: + if field_info.exclude is True: + if field_info.is_required(): + raise TypeError( + c.Cli.ERR_REQUIRED_EXCLUDED_FIELD_FMT.format( + model=model_cls.__name__, field_name=field_name + ) + ) continue parameter, annotation = cls._build_model_parameter( field_name, field_info, settings @@ -42,27 +56,14 @@ def model_command[M: t.Cli.ModelLike]( parameters.append(parameter) annotations[field_name] = annotation command: FlextCliCliPart01._ModelCommand[M] = cls._ModelCommand( - handler=handler, model_cls=model_cls, parameters=parameters + handler=handler, + model_cls=model_cls, + parameters=parameters, + result_border=result_border, ) command.__annotations__ = dict(annotations) return command - @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 Pydantic model from ordered model/mapping sources.""" - merged: t.MutableJsonMapping = {} - for source in sources: - merged.update(u.Cli.model_source_data(model_cls, source)) - if overrides is not None: - merged.update(u.Cli.model_source_data(model_cls, overrides)) - validated: M = model_cls.model_validate(merged) - return validated - @staticmethod def invoke_app( app: p.Cli.Application, @@ -71,14 +72,14 @@ def invoke_app( charset: str = c.Cli.ENCODING_DEFAULT, env: t.StrMapping | None = None, ) -> p.Result[m.Cli.InvocationResult]: - """Invoke an application through the private real-framework boundary.""" - try: - invocation = u.Cli.framework_invoke( - app, args=args, charset=charset, env=env - ) - except (TypeError, ValueError) as exc: - return r[m.Cli.InvocationResult].fail(str(exc), exception=exc) - return r[m.Cli.InvocationResult].ok(invocation) + """Invoke an application through the private real-framework boundary. + + A foreign application or an invalid runner argument is a caller defect + and raises; the command outcome travels in the invocation result. + """ + return r[m.Cli.InvocationResult].ok( + u.Cli.framework_invoke(app, args=args, charset=charset, env=env) + ) __all__: list[str] = ["FlextCliCli"] diff --git a/src/flext_cli/services/_cli_parts/flextclicli_part_05.py b/src/flext_cli/services/_cli_parts/flextclicli_part_05.py index 30fcff73e..729d976b0 100644 --- a/src/flext_cli/services/_cli_parts/flextclicli_part_05.py +++ b/src/flext_cli/services/_cli_parts/flextclicli_part_05.py @@ -39,7 +39,10 @@ def register_result_callback[M: t.Cli.ModelLike, TResult: t.Cli.ResultValue]( success_type=success_type, ) cls.register_callback( - app, command=cls.model_command(model_cls, execute, settings=settings) + app, + command=cls.model_command( + model_cls, execute, settings=settings, result_border=True + ), ) @classmethod @@ -68,7 +71,9 @@ def register_result_command[M: t.Cli.ModelLike, TResult: t.Cli.ResultValue]( app, name=name, help_text=help_text, - command=cls.model_command(model_cls, execute, settings=settings), + command=cls.model_command( + model_cls, execute, settings=settings, result_border=True + ), ) @classmethod @@ -82,16 +87,10 @@ def _build_result_executor[M: t.Cli.ModelLike, TResult: t.Cli.ResultValue]( ) -> p.Cli.ModelCommandHandler[M]: """Build the shared executor used by single and batched route registration.""" - def _exit_with_failure(result: p.Result[TResult]) -> None: - # NOTE (multi-agent): programmatic execution propagates the original - # Result; direct framework execution finalizes it at this boundary. - if not u.Cli.framework_exit_result(result): - cls.exit(code=cls.finalize_result(result)) - def execute(params: M) -> t.JsonValue: result: p.Result[TResult] = handler(params) if result.failure: - _exit_with_failure(result) + cls._exit_failure(result) result_value: TResult = result.value message = u.Cli.commands_resolve_success_message( result_value=result_value, diff --git a/src/flext_cli/services/_cli_parts/flextclicli_part_06.py b/src/flext_cli/services/_cli_parts/flextclicli_part_06.py new file mode 100644 index 000000000..55af958c5 --- /dev/null +++ b/src/flext_cli/services/_cli_parts/flextclicli_part_06.py @@ -0,0 +1,65 @@ +"""FLEXT CLI - Unified Typer abstraction service. + +Copyright (c) 2025 FLEXT Team. All rights reserved. +SPDX-License-Identifier: MIT +""" + +from __future__ import annotations + +from flext_cli import m, p, s, t, u + +from .flextclicli_part_05 import FlextCliCli as FlextCliCliPart05 + + +class FlextCliCli(FlextCliCliPart05): + """Implementation part for FlextCliCli.""" + + @classmethod + def service_routes[S: s]( + cls, service_type: type[S], *, provide: t.Cli.NullaryOperation[S] + ) -> tuple[m.Cli.ResultCommandRoute, ...]: + """Derive one result route per operation of a service class. + + Routes come from ``u.service_operations(service_type)``: the kebab-case + operation name, its summary as help, and its request model or the + shared ``m.Cli.EmptyRequest``. ``provide`` builds the service only when + a command executes, so ``--help`` constructs no adapter. A successful + result value is rendered; rejected input exits non-zero with the + ``ValidationError`` as the failure cause. + """ + return tuple( + m.Cli.ResultCommandRoute( + name=operation.name.replace("_", "-"), + help_text=operation.summary, + model_cls=operation.request or m.Cli.EmptyRequest, + handler=cls._operation_handler(operation, provide), + success_formatter=cls._render_result_value, + ) + for operation in u.service_operations(service_type) + ) + + @staticmethod + def _operation_handler[S: s]( + operation: m.ServiceOperation, provide: t.Cli.NullaryOperation[S] + ) -> p.Cli.ResultRouteHandler: + """Bind one operation to the service instance built at execution.""" + + def handle(params: t.Cli.ModelLike) -> p.Result[t.Cli.ResultValue]: + run = getattr(provide(), operation.name) + result: p.Result[t.Cli.ResultValue] = ( + run() if operation.request is None else run(params) + ) + return result + + return handle + + @staticmethod + def _render_result_value(value: t.Cli.ResultValue) -> str: + """Render a successful operation value as text or JSON.""" + normalized = u.normalize_to_json_value(value) + if isinstance(normalized, str): + return normalized + return u.to_json(normalized).decode() + + +__all__: list[str] = ["FlextCliCli"] diff --git a/src/flext_cli/services/cli.py b/src/flext_cli/services/cli.py index 7f2c5769e..8ee988681 100644 --- a/src/flext_cli/services/cli.py +++ b/src/flext_cli/services/cli.py @@ -8,10 +8,10 @@ from flext_cli import m, s -from ._cli_parts.flextclicli_part_05 import FlextCliCli as FlextCliCliPart05 +from ._cli_parts.flextclicli_part_06 import FlextCliCli as FlextCliCliPart06 -class FlextCliCli(s[m.Cli.RuntimeStatus], FlextCliCliPart05): +class FlextCliCli(s[m.Cli.RuntimeStatus], FlextCliCliPart06): """Public facade for FlextCliCli.""" diff --git a/tests/_models_parts/tests_runtime.py b/tests/_models_parts/tests_runtime.py index 401910a50..bb89a0a40 100644 --- a/tests/_models_parts/tests_runtime.py +++ b/tests/_models_parts/tests_runtime.py @@ -20,14 +20,8 @@ class ApiResponse(m.BaseModel): message: Annotated[str, m.Field(description="Message")] error: Annotated[str | None, m.Field(description="Error")] = None - class ModelCommandSource(m.BaseModel): - """Partial override source for the model-command DSL (all optional).""" - - name: Annotated[str | None, m.Field(description="Command name")] = None - value: Annotated[int | None, m.Field(description="Command value")] = None - class ModelCommandSample(m.BaseModel): - """Target model for derive_model/model_command tests.""" + """Target model for model_command tests.""" name: Annotated[str, m.Field(description="Required command name")] value: Annotated[int, m.Field(description="Command value with default")] = 42 diff --git a/tests/unit/_cases/test_cli_service/testsflextcliservice_part_03.py b/tests/unit/_cases/test_cli_service/testsflextcliservice_part_03.py index a052d9cc4..bd17da6c5 100644 --- a/tests/unit/_cases/test_cli_service/testsflextcliservice_part_03.py +++ b/tests/unit/_cases/test_cli_service/testsflextcliservice_part_03.py @@ -10,8 +10,6 @@ from tests.utilities import u # NOTE (multi-agent, mro-wkii.19.4): app creation owns the settings singleton. -# NOTE (multi-agent, mro-wkii.17 / agent: make_ssot_audit): derive_model tests -# compose canonical source models without JSON-shaped intermediaries. class TestsFlextCliService: @@ -40,10 +38,10 @@ class ExcludedFieldModel(m.BaseModel): tm.that(help_result.value.stdout, has="--visible") tm.that("--hidden" in help_result.value.stdout, eq=False) - def test_create_app_with_common_params_handles_invalid_trace_without_debug( + def test_create_app_with_common_params_rejects_trace_without_debug( self, ) -> None: - """Keep trace disabled when debug is not enabled at the public boundary.""" + """Fail the invocation when shared flags cannot apply to the settings.""" app = cli.create_app_with_common_params(name="warn-app", help_text="Warn app") cli.register_command(app, name="ok", help_text="OK", command=lambda: True) trace_before = settings.trace @@ -51,7 +49,8 @@ def test_create_app_with_common_params_handles_invalid_trace_without_debug( invoke_result = cli.invoke_app(app, args=["--trace", "ok"]) tm.ok(invoke_result) - tm.that(u.Cli.process_succeeded(invoke_result.value.outcome), eq=True) + tm.that(u.Cli.process_succeeded(invoke_result.value.outcome), eq=False) + tm.that(invoke_result.value.stdout, has="debug") tm.that(settings.trace, eq=trace_before) def test_create_app_with_common_params_no_flags_keeps_settings(self) -> None: @@ -69,24 +68,6 @@ def test_create_app_with_common_params_no_flags_keeps_settings(self) -> None: tm.that(u.Cli.process_succeeded(invoke_result.value.outcome), eq=True) tm.that(settings.model_dump(include=shared_flags), eq=flags_before) - def test_derive_model_merges_canonical_model_sources(self) -> None: - """Merge ordered canonical model sources without model-less payloads.""" - first_source = m.Tests.SampleInput(name="alice", count=2) - model_from_instance = m.Tests.SampleInput( - name="bob", count=7, dry_run=True, output_format=c.Cli.OutputFormats.JSON - ) - final_source = m.Tests.SampleInput( - name="carol", count=9, dry_run=True, output_format=c.Cli.OutputFormats.JSON - ) - - derived = cli.derive_model( - m.Tests.SampleInput, first_source, model_from_instance, final_source - ) - - tm.that(derived.name, eq="carol") - tm.that(derived.count, eq=9) - tm.that(derived.dry_run, eq=True) - def test_execute_app_propagates_unexpected_exception(self) -> None: """Propagate unexpected command defects with their original cause.""" app = cli.create_app_with_common_params(name="error-app", help_text="Error app") @@ -100,5 +81,3 @@ def test_execute_app_propagates_unexpected_exception(self) -> None: with pytest.raises(ValueError, match="boom"): cli.execute_app(app, prog_name="error-app", args=["boom"]) - -__all__: list[str] = ["TestsFlextCliService"] 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 9a9a27e0c..a8e734449 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 @@ -4,25 +4,13 @@ from flext_tests import tm -from flext_cli import FlextCliSettings, cli, m, settings +from flext_cli import FlextCliSettings, cli, settings from tests import c, p, u class TestsFlextCliPublicContractsCoverage: """Implementation part for TestsFlextCliPublicContractsCoverage.""" - class _CommandModel(m.BaseModel): - """Minimal public model for utility command construction.""" - - label: str - debug: bool = False - - class _CommandSource(m.BaseModel): - """Source model used to exercise `u.Cli.model_source_data()`.""" - - label: str - debug: bool | None = None - def test_public_facade_and_settings_contract(self) -> None: # NOTE (multi-agent): flat cli_* settings (§2.6) — fresh instances come # from ``settings.clone()`` and test-runtime detection lives in @@ -55,24 +43,3 @@ 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_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) - ), - eq={"label": "mapped"}, - ) - - derived = cli.derive_model( - self._CommandModel, - {"label": "base"}, - {"debug": True}, - overrides={"label": "override"}, - ) - - tm.that(derived.label, eq="override") - tm.that(derived.debug, eq=True) - - -__all__: list[str] = ["TestsFlextCliPublicContractsCoverage"] diff --git a/tests/unit/test_model_commands_cov.py b/tests/unit/test_model_commands_cov.py index f9d061061..361408323 100644 --- a/tests/unit/test_model_commands_cov.py +++ b/tests/unit/test_model_commands_cov.py @@ -1,6 +1,6 @@ """Behavioral tests for the public model-command DSL on ``cli``. -Covers the observable contract of ``cli.derive_model`` and ``cli.model_command``: +Covers the observable contract of ``cli.model_command``: returned/validated model state, handler dispatch, settings-seeded defaults, default resolution, and error propagation via the Pydantic validation family. No private attribute access, no internal-collaborator spying, no signature @@ -22,62 +22,8 @@ class TestsFlextCliModelCommandsCov: """Behavioral contract of the public model-command helpers.""" - # ---- derive_model --------------------------------------------------- - - @pytest.mark.parametrize( - ("payload", "expected_name", "expected_value"), - [ - (m.Tests.ModelCommandSource(name="hello", value=7), "hello", 7), - (m.Tests.ModelCommandSource(name="only-required"), "only-required", 42), - (m.Tests.ModelCommandSource(name="zero", value=0), "zero", 0), - ], - ) - def test_derive_model_from_source_model_applies_defaults( - self, - payload: m.Tests.ModelCommandSource, - expected_name: str, - expected_value: int, - ) -> None: - """Verify that derive model from source model applies defaults.""" - result = cli.derive_model(m.Tests.ModelCommandSample, payload) - - tm.that(result, is_=m.Tests.ModelCommandSample) - tm.that(result.name, eq=expected_name) - tm.that(result.value, eq=expected_value) - - def test_derive_model_from_model_instance_preserves_field_values(self) -> None: - """Verify that derive model from model instance preserves field values.""" - source = m.Tests.ModelCommandNullable(name="test", optional=None) - - result = cli.derive_model(m.Tests.ModelCommandNullable, source) - - tm.that(result.name, eq="test") - tm.that(result.optional, none=True) - - def test_derive_model_partial_source_takes_precedence(self) -> None: - """Verify that derive model partial source takes precedence.""" - result = cli.derive_model( - m.Tests.ModelCommandSample, - m.Tests.ModelCommandSource(name="base", value=1), - m.Tests.ModelCommandSource(value=99), - ) - - tm.that(result.name, eq="base") - tm.that(result.value, eq=99) - - def test_derive_model_later_source_wins_over_earlier(self) -> None: - """Verify that derive model later source wins over earlier.""" - result = cli.derive_model( - m.Tests.ModelCommandSample, - m.Tests.ModelCommandSource(name="first"), - m.Tests.ModelCommandSource(name="second", value=5), - ) - - tm.that(result.name, eq="second") - tm.that(result.value, eq=5) - - def test_derive_model_rejects_invalid_data_with_validation_error(self) -> None: - """Verify that derive model rejects invalid data with validation error.""" + def test_model_command_rejects_invalid_data_with_validation_error(self) -> None: + """A plain model command raises the ValidationError of rejected input.""" command = cli.model_command( m.Tests.ModelCommandSample, lambda model: model.value ) @@ -85,13 +31,6 @@ def test_derive_model_rejects_invalid_data_with_validation_error(self) -> None: with pytest.raises(m.ValidationError): command(name="invalid", value="not-an-int") - def test_derive_model_rejects_missing_required_field(self) -> None: - """Verify that derive model rejects missing required field.""" - with pytest.raises(m.ValidationError): - cli.derive_model( - m.Tests.ModelCommandSample, m.Tests.ModelCommandSource(value=1) - ) - # ---- model_command -------------------------------------------------- def test_model_command_dispatches_to_handler_with_bound_model(self) -> None: diff --git a/tests/unit/test_service_routes.py b/tests/unit/test_service_routes.py new file mode 100644 index 000000000..937e370b1 --- /dev/null +++ b/tests/unit/test_service_routes.py @@ -0,0 +1,116 @@ +"""Behavioral tests for ``cli.service_routes`` through a real Typer app. + +Service operations become commands: the valid command renders its result, +rejected input exits non-zero with the validation cause, an input-less +operation runs without options, and ``--help`` never builds the service. +""" + +from __future__ import annotations + +import pytest +from flext_tests import tm + +from flext_cli import cli, r, s +from tests import m, p, t, u + + +class TestsFlextCliServiceRoutes: + """Behavioral contract of service-derived CLI routes.""" + + class Greeting(m.BaseModel): + """Request of the greet operation.""" + + name: str + times: int = m.Field(default=1, ge=1) + + class Greeter(s[bool]): + """Service whose public operations are the CLI.""" + + def execute(self) -> p.Result[bool]: + """Run the default service action.""" + return r[bool].ok(True) + + def greet_all( + self, request: TestsFlextCliServiceRoutes.Greeting + ) -> p.Result[str]: + """Greet someone by name.""" + return r[str].ok(" ".join([f"hello {request.name}"] * request.times)) + + def status(self) -> p.Result[dict[str, int]]: + """Report the service status.""" + return r[dict[str, int]].ok({"ready": 1}) + + @staticmethod + def _app( + provide: t.Cli.NullaryOperation[TestsFlextCliServiceRoutes.Greeter], + ) -> p.Cli.Application: + app = cli.create_app_with_common_params(name="greeter", help_text="Greeter") + cli.register_result_routes( + app, cli.service_routes(TestsFlextCliServiceRoutes.Greeter, provide=provide) + ) + return app + + @staticmethod + def _unconfigured() -> TestsFlextCliServiceRoutes.Greeter: + msg = "adapter environment is not configured" + raise RuntimeError(msg) + + def test_valid_command_renders_result_value(self) -> None: + """A valid invocation exits zero and renders the operation value.""" + app = self._app(self.Greeter) + + outcome = tm.ok( + cli.invoke_app(app, args=["greet-all", "--name", "ana", "--times", "2"]) + ) + + tm.that(u.Cli.process_succeeded(outcome.outcome), eq=True) + tm.that(outcome.stdout, has="hello ana hello ana") + + def test_invalid_input_exits_non_zero_with_validation_cause(self) -> None: + """Rejected input fails the command with the ValidationError cause.""" + app = self._app(self.Greeter) + + outcome = tm.ok( + cli.invoke_app(app, args=["greet-all", "--name", "ana", "--times", "0"]) + ) + + tm.that(u.Cli.process_succeeded(outcome.outcome), eq=False) + tm.that(outcome.stdout, has=["Greeting", "greater than or equal to 1"]) + + def test_input_less_operation_renders_json(self) -> None: + """An operation without a request model runs with no options.""" + app = self._app(self.Greeter) + + outcome = tm.ok(cli.invoke_app(app, args=["status"])) + + tm.that(u.Cli.process_succeeded(outcome.outcome), eq=True) + tm.that(outcome.stdout, has='{"ready":1}') + + @pytest.mark.parametrize( + "args", [["--help"], ["greet-all", "--help"], ["status", "--help"]] + ) + def test_help_builds_no_adapter(self, args: list[str]) -> None: + """Help renders from the class; the unconfigured provider never runs.""" + app = self._app(self._unconfigured) + + outcome = tm.ok(cli.invoke_app(app, args=args)) + + tm.that(u.Cli.process_succeeded(outcome.outcome), eq=True) + tm.that(outcome.stdout, has="Usage") + + def test_execution_reaches_the_provider(self) -> None: + """Executing a command builds the service through the provider.""" + app = self._app(self._unconfigured) + + with pytest.raises(RuntimeError, match="not configured"): + cli.execute_app(app, prog_name="greeter", args=["status"]) + + def test_required_excluded_request_field_fails_at_build(self) -> None: + """A required field that no option can supply is a build-time defect.""" + + class Hidden(m.BaseModel): + token: str = m.Field(exclude=True) + + with pytest.raises(TypeError, match="token"): + cli.model_command(Hidden, lambda _params: True) + diff --git a/uv.lock b/uv.lock index 281530ddc..0db3801df 100644 --- a/uv.lock +++ b/uv.lock @@ -575,7 +575,7 @@ dev = [ [[package]] name = "flext-core" version = "0.12.0" -source = { git = "https://github.com/flext-sh/flext-core.git?rev=0.12.0-dev#8de52fa6b117ef54a840ac655c4518aff26ba027" } +source = { git = "https://github.com/flext-sh/flext-core.git?rev=0.12.0-dev#8967df28975018881911c0a4a9b8a872b32aa56b" } dependencies = [ { name = "annotated-types" }, { name = "beartype" }, @@ -1249,11 +1249,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.11.15" +version = "4.12.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/e0/7c20b5d0e0f147c40a934e8f9e9f717bd6e3e372c4d58ca5c2fcce2b1652/platformdirs-4.11.15.tar.gz", hash = "sha256:d7419e973b2b740d428200130f80c0e79304d1c71081001db911e15b26a3a6d4", size = 46059, upload-time = "2026-09-26T02:04:58.142Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/4d/e78afe1b449720c481884ca0a2f960f85f9ffdaa34b2d127b5427422c564/platformdirs-4.12.0.tar.gz", hash = "sha256:095be5c143382b1bee917c4f3e9987a0d8d6a582261f1d061ad0c403b7695b5b", size = 58964, upload-time = "2026-09-26T15:26:00.937Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/83/246d3052d043683cc5f400ab5748bd07a99fbdf311dc771cfad7e3f9e7e0/platformdirs-4.11.15-py3-none-any.whl", hash = "sha256:84508421c77d3c6462e71dd8c0d2776c2ace0e94eb006dc4fd383bc1dcf5a176", size = 26689, upload-time = "2026-09-26T02:04:56.71Z" }, + { url = "https://files.pythonhosted.org/packages/60/3a/723ad221b7fef91ccd947f9fddbe84d38fa573e2723317aaa67bfde41015/platformdirs-4.12.0-py3-none-any.whl", hash = "sha256:f6fb2960f2f2eb0870f820f7e49e49e6c0f0589c637f34d33b260b07591327c9", size = 32965, upload-time = "2026-09-26T15:25:59.496Z" }, ] [[package]] From 53aec3727c950bd8ffda2fec954ea95caa604110 Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Sat, 26 Sep 2026 15:34:59 -0300 Subject: [PATCH 2/6] feat(cli): service_routes derives commands from service operations (V8 S5) cli.service_routes(Service, provide=...) builds one m.Cli.ResultCommandRoute per u.service_operations entry (kebab name, summary help, request model or the shared m.Cli.EmptyRequest); the service is built only at execution through provide, so --help builds no adapter; success renders the value; invalid input exits non-zero with the ValidationError cause. The duplicate model_commands utility and derive_model are removed (zero uses in the fleet). The test service returns models, never loose dicts (R29). Locked to flext-core 150f3c75 (S2+S3 merged). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../test_cli_service/testsflextcliservice_part_03.py | 3 +-- .../testsflextclipubliccontractscoverage_part_01.py | 1 - tests/unit/test_service_routes.py | 12 +++++++++--- uv.lock | 4 ++-- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/unit/_cases/test_cli_service/testsflextcliservice_part_03.py b/tests/unit/_cases/test_cli_service/testsflextcliservice_part_03.py index bd17da6c5..a9ec6e68f 100644 --- a/tests/unit/_cases/test_cli_service/testsflextcliservice_part_03.py +++ b/tests/unit/_cases/test_cli_service/testsflextcliservice_part_03.py @@ -6,7 +6,7 @@ from flext_tests import tm from flext_cli import cli, settings -from tests import c, m +from tests import m from tests.utilities import u # NOTE (multi-agent, mro-wkii.19.4): app creation owns the settings singleton. @@ -80,4 +80,3 @@ def test_execute_app_propagates_unexpected_exception(self) -> None: with pytest.raises(ValueError, match="boom"): cli.execute_app(app, prog_name="error-app", args=["boom"]) - 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 a8e734449..ac6ec055c 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 @@ -42,4 +42,3 @@ def test_public_facade_and_settings_contract(self) -> None: tm.ok(facade_result) tm.that(facade_result.value.status, eq=(c.Cli.ServiceStatus.OPERATIONAL)) tm.that(facade_result.value.service, eq=c.Cli.FLEXT_CLI) - diff --git a/tests/unit/test_service_routes.py b/tests/unit/test_service_routes.py index 937e370b1..f64241f7c 100644 --- a/tests/unit/test_service_routes.py +++ b/tests/unit/test_service_routes.py @@ -23,6 +23,11 @@ class Greeting(m.BaseModel): name: str times: int = m.Field(default=1, ge=1) + class Status(m.BaseModel): + """Result of the status operation.""" + + ready: int + class Greeter(s[bool]): """Service whose public operations are the CLI.""" @@ -36,9 +41,11 @@ def greet_all( """Greet someone by name.""" return r[str].ok(" ".join([f"hello {request.name}"] * request.times)) - def status(self) -> p.Result[dict[str, int]]: + def status(self) -> p.Result[TestsFlextCliServiceRoutes.Status]: """Report the service status.""" - return r[dict[str, int]].ok({"ready": 1}) + return r[TestsFlextCliServiceRoutes.Status].ok( + TestsFlextCliServiceRoutes.Status(ready=1) + ) @staticmethod def _app( @@ -113,4 +120,3 @@ class Hidden(m.BaseModel): with pytest.raises(TypeError, match="token"): cli.model_command(Hidden, lambda _params: True) - diff --git a/uv.lock b/uv.lock index 0db3801df..228e87f62 100644 --- a/uv.lock +++ b/uv.lock @@ -575,7 +575,7 @@ dev = [ [[package]] name = "flext-core" version = "0.12.0" -source = { git = "https://github.com/flext-sh/flext-core.git?rev=0.12.0-dev#8967df28975018881911c0a4a9b8a872b32aa56b" } +source = { git = "https://github.com/flext-sh/flext-core.git?rev=0.12.0-dev#150f3c757c009515a324febe0cda229a7a52dd03" } dependencies = [ { name = "annotated-types" }, { name = "beartype" }, @@ -591,7 +591,7 @@ dependencies = [ [[package]] name = "flext-infra" version = "0.12.0" -source = { git = "https://github.com/flext-sh/flext-infra.git?rev=0.12.0-dev#a4898b561147835857017a356006a1f723a39aa3" } +source = { git = "https://github.com/flext-sh/flext-infra.git?rev=0.12.0-dev#a3eba48958c6141d4bc03f97857fd5963f966bbd" } dependencies = [ { name = "annotated-types" }, { name = "beartype" }, From 9f06b81eb4b08ef010c4268667182ec417e6d09a Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Sat, 26 Sep 2026 15:39:46 -0300 Subject: [PATCH 3/6] fix(cli): fail loud through unwrap and validated params; operation names avoid base members Applying common params unwraps the settings result (the cause travels with the raised error) instead of routing a Settings result through the ResultValue-bounded exit path; next params are built by validating through CliParamsConfig, not model_copy(update=) (R22). The route test's input-less operation is named report: status is a member every FlextCliServiceBase inherits, so discovery excludes it by design; execute carries @override. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../services/_cli_parts/flextclicli_part_02.py | 12 +++++++----- tests/unit/test_service_routes.py | 11 +++++++---- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/flext_cli/services/_cli_parts/flextclicli_part_02.py b/src/flext_cli/services/_cli_parts/flextclicli_part_02.py index 7d8cd34ee..9d2df05e2 100644 --- a/src/flext_cli/services/_cli_parts/flextclicli_part_02.py +++ b/src/flext_cli/services/_cli_parts/flextclicli_part_02.py @@ -22,11 +22,13 @@ def _apply_common_params_to_config(self, *, params: m.Cli.CliParamsConfig) -> No resolved_log_level: str = ( params.log_level if params.log_level is not None else settings.cli_log_level ) - next_params = params.model_copy(update={"log_level": resolved_log_level}) - result = FlextCliCommonParams.apply_to_config(settings, params=next_params) - if result.failure: - self._exit_failure(result) - self._apply_updated_settings(result.value) + next_params = m.Cli.CliParamsConfig.model_validate({ + **params.model_dump(), + "log_level": resolved_log_level, + }) + self._apply_updated_settings( + FlextCliCommonParams.apply_to_config(settings, params=next_params).unwrap() + ) @staticmethod def _apply_updated_settings(updated_settings: p.Cli.Settings) -> None: diff --git a/tests/unit/test_service_routes.py b/tests/unit/test_service_routes.py index f64241f7c..62fc20457 100644 --- a/tests/unit/test_service_routes.py +++ b/tests/unit/test_service_routes.py @@ -7,6 +7,8 @@ from __future__ import annotations +from typing import override + import pytest from flext_tests import tm @@ -31,6 +33,7 @@ class Status(m.BaseModel): class Greeter(s[bool]): """Service whose public operations are the CLI.""" + @override def execute(self) -> p.Result[bool]: """Run the default service action.""" return r[bool].ok(True) @@ -41,7 +44,7 @@ def greet_all( """Greet someone by name.""" return r[str].ok(" ".join([f"hello {request.name}"] * request.times)) - def status(self) -> p.Result[TestsFlextCliServiceRoutes.Status]: + def report(self) -> p.Result[TestsFlextCliServiceRoutes.Status]: """Report the service status.""" return r[TestsFlextCliServiceRoutes.Status].ok( TestsFlextCliServiceRoutes.Status(ready=1) @@ -88,13 +91,13 @@ def test_input_less_operation_renders_json(self) -> None: """An operation without a request model runs with no options.""" app = self._app(self.Greeter) - outcome = tm.ok(cli.invoke_app(app, args=["status"])) + outcome = tm.ok(cli.invoke_app(app, args=["report"])) tm.that(u.Cli.process_succeeded(outcome.outcome), eq=True) tm.that(outcome.stdout, has='{"ready":1}') @pytest.mark.parametrize( - "args", [["--help"], ["greet-all", "--help"], ["status", "--help"]] + "args", [["--help"], ["greet-all", "--help"], ["report", "--help"]] ) def test_help_builds_no_adapter(self, args: list[str]) -> None: """Help renders from the class; the unconfigured provider never runs.""" @@ -110,7 +113,7 @@ def test_execution_reaches_the_provider(self) -> None: app = self._app(self._unconfigured) with pytest.raises(RuntimeError, match="not configured"): - cli.execute_app(app, prog_name="greeter", args=["status"]) + cli.execute_app(app, prog_name="greeter", args=["report"]) def test_required_excluded_request_field_fails_at_build(self) -> None: """A required field that no option can supply is a build-time defect.""" From 87bec1967690d840dd8d45d53ed53b0c1ba38174 Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Sat, 26 Sep 2026 15:44:52 -0300 Subject: [PATCH 4/6] fix(cli): service_routes accepts services whose result is a model FlextCliServiceBase requires a p.Base result, so the route builder bounds services by s[p.Base] (the flext-cli service contract) instead of the bare base, whose default parameter rejected every other result model. The test service returns its Status model from execute (R29). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/flext_cli/services/_cli_parts/flextclicli_part_06.py | 4 ++-- tests/unit/test_service_routes.py | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/flext_cli/services/_cli_parts/flextclicli_part_06.py b/src/flext_cli/services/_cli_parts/flextclicli_part_06.py index 55af958c5..d6ae03d6b 100644 --- a/src/flext_cli/services/_cli_parts/flextclicli_part_06.py +++ b/src/flext_cli/services/_cli_parts/flextclicli_part_06.py @@ -15,7 +15,7 @@ class FlextCliCli(FlextCliCliPart05): """Implementation part for FlextCliCli.""" @classmethod - def service_routes[S: s]( + def service_routes[S: s[p.Base]]( cls, service_type: type[S], *, provide: t.Cli.NullaryOperation[S] ) -> tuple[m.Cli.ResultCommandRoute, ...]: """Derive one result route per operation of a service class. @@ -39,7 +39,7 @@ def service_routes[S: s]( ) @staticmethod - def _operation_handler[S: s]( + def _operation_handler[S: s[p.Base]]( operation: m.ServiceOperation, provide: t.Cli.NullaryOperation[S] ) -> p.Cli.ResultRouteHandler: """Bind one operation to the service instance built at execution.""" diff --git a/tests/unit/test_service_routes.py b/tests/unit/test_service_routes.py index 62fc20457..52cb5109c 100644 --- a/tests/unit/test_service_routes.py +++ b/tests/unit/test_service_routes.py @@ -30,13 +30,15 @@ class Status(m.BaseModel): ready: int - class Greeter(s[bool]): + class Greeter(s[Status]): """Service whose public operations are the CLI.""" @override - def execute(self) -> p.Result[bool]: + def execute(self) -> p.Result[TestsFlextCliServiceRoutes.Status]: """Run the default service action.""" - return r[bool].ok(True) + return r[TestsFlextCliServiceRoutes.Status].ok( + TestsFlextCliServiceRoutes.Status(ready=1) + ) def greet_all( self, request: TestsFlextCliServiceRoutes.Greeting From 998332ee37e92c6595994f4351ebca7f8b293a95 Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Sat, 26 Sep 2026 15:53:20 -0300 Subject: [PATCH 5/6] fix(cli): service_routes is generic in the service result FlextCliServiceBase is invariant in its result, like every generic Pydantic model, so s[Status] is not s[p.Base]. The route builder binds R: p.Base and accepts type[s[R]] with a provider of s[R], so any model-returning service resolves R exactly. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/flext_cli/services/_cli_parts/flextclicli_part_06.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/flext_cli/services/_cli_parts/flextclicli_part_06.py b/src/flext_cli/services/_cli_parts/flextclicli_part_06.py index d6ae03d6b..1567595f0 100644 --- a/src/flext_cli/services/_cli_parts/flextclicli_part_06.py +++ b/src/flext_cli/services/_cli_parts/flextclicli_part_06.py @@ -15,8 +15,8 @@ class FlextCliCli(FlextCliCliPart05): """Implementation part for FlextCliCli.""" @classmethod - def service_routes[S: s[p.Base]]( - cls, service_type: type[S], *, provide: t.Cli.NullaryOperation[S] + def service_routes[R: p.Base]( + cls, service_type: type[s[R]], *, provide: t.Cli.NullaryOperation[s[R]] ) -> tuple[m.Cli.ResultCommandRoute, ...]: """Derive one result route per operation of a service class. @@ -39,8 +39,8 @@ def service_routes[S: s[p.Base]]( ) @staticmethod - def _operation_handler[S: s[p.Base]]( - operation: m.ServiceOperation, provide: t.Cli.NullaryOperation[S] + def _operation_handler[R: p.Base]( + operation: m.ServiceOperation, provide: t.Cli.NullaryOperation[s[R]] ) -> p.Cli.ResultRouteHandler: """Bind one operation to the service instance built at execution.""" From aafe0d7a29b0b173341f63f53f28f411cab9adf8 Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Sat, 26 Sep 2026 15:56:33 -0300 Subject: [PATCH 6/6] fix(cli): common-param failures exit through the result border with their cause Raising through unwrap lost the rendered cause at the CLI border (the rejected --trace without --debug exited non-zero with empty output). The failure is re-typed with r[bool].from_failure, the result API for carrying a failure across value types, and leaves through _exit_failure, which renders it and exits non-zero. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/flext_cli/services/_cli_parts/flextclicli_part_02.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/flext_cli/services/_cli_parts/flextclicli_part_02.py b/src/flext_cli/services/_cli_parts/flextclicli_part_02.py index 9d2df05e2..0ca98a4b0 100644 --- a/src/flext_cli/services/_cli_parts/flextclicli_part_02.py +++ b/src/flext_cli/services/_cli_parts/flextclicli_part_02.py @@ -8,7 +8,7 @@ from inspect import Parameter -from flext_cli import m, p, settings, t, u +from flext_cli import m, p, r, settings, t, u from flext_cli.services.cli_params import FlextCliCommonParams from .flextclicli_part_01 import FlextCliCli as FlextCliCliPart01 @@ -26,9 +26,10 @@ def _apply_common_params_to_config(self, *, params: m.Cli.CliParamsConfig) -> No **params.model_dump(), "log_level": resolved_log_level, }) - self._apply_updated_settings( - FlextCliCommonParams.apply_to_config(settings, params=next_params).unwrap() - ) + applied = FlextCliCommonParams.apply_to_config(settings, params=next_params) + if applied.failure: + self._exit_failure(r[bool].from_failure(applied)) + self._apply_updated_settings(applied.value) @staticmethod def _apply_updated_settings(updated_settings: p.Cli.Settings) -> None: