diff --git a/src/flext_cli/_constants/settings.py b/src/flext_cli/_constants/settings.py index 38dd31ae5..6c991c4b3 100644 --- a/src/flext_cli/_constants/settings.py +++ b/src/flext_cli/_constants/settings.py @@ -47,6 +47,10 @@ class FlextCliConstantsSettings: FLEXT_CLI: ClassVar[str] = "flext-cli" CLI_VERSION: ClassVar[str] = "2.0.0" OPTIONAL_UNION_ARG_COUNT: ClassVar[int] = 2 + CLI_JSON_OPTION_METAVAR: ClassVar[str] = "JSON" + CLI_JSON_OPTION_HELP: ClassVar[str] = ( + "Format: JSON text, validated into the option's declared type." + ) CLI_SCALAR_TYPES_TUPLE: ClassVar[ tuple[type[str], type[int], type[float], type[bool]] ] = t.PRIMITIVES_TYPES diff --git a/src/flext_cli/_typings/domain.py b/src/flext_cli/_typings/domain.py index 560017cc7..3fb080823 100644 --- a/src/flext_cli/_typings/domain.py +++ b/src/flext_cli/_typings/domain.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Callable, MutableMapping +from collections.abc import Callable from pathlib import Path from ruamel.yaml.comments import CommentedMap, CommentedSeq @@ -49,7 +49,6 @@ class FlextCliTypesDomain: | FlextCliTypesDomain.RuleCatalog[TFileRuleKind] | None ) - type MutableDefaultMapping = MutableMapping[str, t.Scalar | t.StrSequence] type CliParamValue = bool | str type CliParamKwargs = t.MappingKV[str, CliParamValue] type DefaultAtom = t.Scalar | t.StrSequence diff --git a/src/flext_cli/_utilities/_options_parts/flextcliutilitiesoptions_part_01.py b/src/flext_cli/_utilities/_options_parts/flextcliutilitiesoptions_part_01.py index 569c28b61..0e0217629 100644 --- a/src/flext_cli/_utilities/_options_parts/flextcliutilitiesoptions_part_01.py +++ b/src/flext_cli/_utilities/_options_parts/flextcliutilitiesoptions_part_01.py @@ -2,23 +2,70 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from pathlib import Path from types import GenericAlias, NoneType, UnionType from typing import Annotated, TypeAliasType, get_args, get_origin from flext_cli import c, t +from flext_cli.models import m class FlextCliUtilitiesOptions: """Implementation part for FlextCliUtilitiesOptions.""" @staticmethod - def resolve_typer_annotation( + def unwrap_annotation( annotation: t.Cli.RuntimeAnnotation, - ) -> type | GenericAlias: - """Resolve runtime annotations to concrete types accepted by Typer.""" + ) -> t.Cli.RuntimeAnnotation: + """Strip type aliases and ``Annotated`` metadata down to the carried type.""" annotated_origin = get_origin(Annotated[str, "meta"]) + resolved = annotation + while ( + isinstance(resolved, TypeAliasType) + or get_origin(resolved) == annotated_origin + ): + resolved = ( + resolved.__value__ + if isinstance(resolved, TypeAliasType) + else get_args(resolved)[0] + ) + return resolved + + @classmethod + def is_json_option(cls, annotation: t.Cli.RuntimeAnnotation) -> bool: + """Return True when a field has no native CLI form and travels as JSON. + + Mappings, nested models, and the collections or unions that carry them + are exposed as one JSON option that Pydantic validates into the + field's declared type. + """ + resolved = cls.unwrap_annotation(annotation) + if isinstance(resolved, UnionType): + return any(cls.is_json_option(arg) for arg in get_args(resolved)) + origin = get_origin(resolved) + while isinstance(origin, TypeAliasType): + origin = get_origin(origin.__value__) + carrier = resolved if origin is None else origin + if isinstance(carrier, type) and ( + issubclass(carrier, m.BaseModel) or issubclass(carrier, Mapping) + ): + return True + return origin is not None and any( + cls.is_json_option(arg) for arg in get_args(resolved) + ) + + @classmethod + def resolve_typer_annotation( + cls, annotation: t.Cli.RuntimeAnnotation + ) -> type | GenericAlias: + """Resolve runtime annotations to concrete types accepted by Typer. + + A field without a native CLI form (see ``is_json_option``) resolves to + ``str``: its option carries JSON that Pydantic validates on parse. + """ + if cls.is_json_option(annotation): + return str sequence_origins: frozenset[object] = frozenset( filter( None, @@ -37,23 +84,12 @@ def resolve_typer_annotation( ], ) ) - mapping_origin = get_origin(dict[str, t.Scalar]) - resolved_annotation_input = annotation + resolved_annotation_input = cls.unwrap_annotation(annotation) origin = get_origin(resolved_annotation_input) - while ( - isinstance(resolved_annotation_input, TypeAliasType) - or origin == annotated_origin - ): - resolved_annotation_input = ( - resolved_annotation_input.__value__ - if isinstance(resolved_annotation_input, TypeAliasType) - else get_args(resolved_annotation_input)[0] - ) - origin = get_origin(resolved_annotation_input) if isinstance(resolved_annotation_input, UnionType): resolved_args = tuple( - FlextCliUtilitiesOptions.resolve_typer_annotation(arg) + cls.resolve_typer_annotation(arg) for arg in get_args(resolved_annotation_input) ) non_none_args = tuple(arg for arg in resolved_args if arg is not NoneType) @@ -66,15 +102,10 @@ def resolve_typer_annotation( if origin in sequence_origins: inner_annotation = next(iter(get_args(resolved_annotation_input)), str) - resolved_inner = FlextCliUtilitiesOptions.resolve_typer_annotation( - inner_annotation - ) + resolved_inner = cls.resolve_typer_annotation(inner_annotation) sequence_item = resolved_inner if isinstance(resolved_inner, type) else str return GenericAlias(list, (sequence_item,)) - if origin == mapping_origin: - return dict - return ( resolved_annotation_input if isinstance(resolved_annotation_input, GenericAlias | type) 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 88bef2ef6..5c564991c 100644 --- a/src/flext_cli/_utilities/_options_parts/flextcliutilitiesoptions_part_02.py +++ b/src/flext_cli/_utilities/_options_parts/flextcliutilitiesoptions_part_02.py @@ -2,10 +2,9 @@ from __future__ import annotations -from collections.abc import Mapping - from flext_cli import c, t from flext_cli.models import m +from flext_core import u from .flextcliutilitiesoptionbuilder_part_01 import FlextCliUtilitiesOptionBuilder from .flextcliutilitiesoptions_part_01 import ( @@ -29,6 +28,9 @@ def field_default( if callable(default_factory) else getattr(field_info, "default", 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 @@ -42,13 +44,6 @@ def field_default( normalized_atom := cls.normalize_cli_atom(normalized_source) ) is not None: normalized_default: t.Cli.CliValue | None = normalized_atom - case Mapping() as normalized_source_mapping: - normalized_mapping: t.Cli.MutableDefaultMapping = {} - for key, item_value in normalized_source_mapping.items(): - normalized_item = cls.normalize_cli_atom(item_value) - if normalized_item is not None: - normalized_mapping[key] = normalized_item - normalized_default = normalized_mapping or None case _ if cls.is_string_sequence(normalized_source): normalized_default = t.Cli.STR_SEQUENCE_ADAPTER.validate_python( normalized_source diff --git a/src/flext_cli/_utilities/framework.py b/src/flext_cli/_utilities/framework.py index 8f1b03673..f34f8ee4a 100644 --- a/src/flext_cli/_utilities/framework.py +++ b/src/flext_cli/_utilities/framework.py @@ -174,16 +174,38 @@ def framework_register_command( @staticmethod def framework_build_parameter( - field_name: str, annotation: type | GenericAlias, spec: m.Cli.OptionSpec + field_name: str, + annotation: type | GenericAlias, + spec: m.Cli.OptionSpec, + *, + json_annotation: t.Cli.RuntimeAnnotation | None = None, ) -> Parameter: - """Build one inspect parameter with a private Typer option default.""" + """Build one inspect parameter with a private Typer option default. + + With ``json_annotation`` the option carries JSON text that Pydantic + validates into that declared type while Click parses the argument, so + malformed JSON or a schema mismatch is a usage error with its cause. + The adapter is built only on parse; rendering help never builds it. + """ option_default: t.Cli.CliValue | EllipsisType | None = ( ... if spec.required else spec.default ) + + def json_option(raw: str) -> t.JsonPayload: + adapter: t.ValueAdapter[t.JsonPayload] = t.TypeAdapter(json_annotation) + try: + return adapter.validate_json(raw) + except ValueError as exc: + # Click's parser hook discards a ValueError's text; the usage + # error carries the Pydantic cause and chains the original. + raise typer.BadParameter(str(exc)) from exc + option = OptionInfo( default=option_default, param_decls=list(spec.declarations), help=spec.help_text or None, + parser=None if json_annotation is None else json_option, + metavar=None if json_annotation is None else c.Cli.CLI_JSON_OPTION_METAVAR, ) return Parameter( field_name, 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 97195df1e..2cf807aaf 100644 --- a/src/flext_cli/services/_cli_parts/flextclicli_part_01.py +++ b/src/flext_cli/services/_cli_parts/flextclicli_part_01.py @@ -10,7 +10,7 @@ from inspect import Parameter, Signature from types import GenericAlias -from flext_cli import m, p, t, u +from flext_cli import c, m, p, t, u class FlextCliCli: @@ -69,9 +69,14 @@ def _build_model_parameter( candidate = f"--{choice.replace('_', '-')}" if candidate != option_name and candidate not in extra_option_names: extra_option_names.append(candidate) - annotation = u.Cli.resolve_typer_annotation( - getattr(field_info, "annotation", None) or str + field_annotation = getattr(field_info, "annotation", None) or str + annotation = u.Cli.resolve_typer_annotation(field_annotation) + json_annotation = ( + field_annotation if u.Cli.is_json_option(field_annotation) else None ) + help_text = getattr(field_info, "description", None) or "" + if json_annotation is not None: + help_text = f"{help_text} {c.Cli.CLI_JSON_OPTION_HELP}".strip() is_required = field_info.is_required() default_value: t.Cli.CliValue | None = ( None @@ -92,12 +97,14 @@ def _build_model_parameter( option_decls = custom_param_decls spec = m.Cli.OptionSpec( declarations=tuple(option_decls), - help_text=getattr(field_info, "description", None) or "", + help_text=help_text, default=default_value, required=is_required, ) return ( - u.Cli.framework_build_parameter(field_name, annotation, spec), + u.Cli.framework_build_parameter( + field_name, annotation, spec, json_annotation=json_annotation + ), annotation, ) diff --git a/src/flext_cli/services/_prompts_support.py b/src/flext_cli/services/_prompts_support.py index 4655ea7d8..5ebca2621 100644 --- a/src/flext_cli/services/_prompts_support.py +++ b/src/flext_cli/services/_prompts_support.py @@ -130,23 +130,10 @@ def _print_message( message: str, log_level: str, message_format: str, - error_message_template: str, ) -> p.Result[bool]: - try: - formatted_message = message_format.format(message=message) - self._log(log_level, formatted_message) - return r[bool].ok(True) - except c.Cli.CLI_SAFE_EXCEPTIONS as exc: - self.logger.exception( - "FAILED to print message - operation aborted", - operation="_print_message", - log_level=log_level, - prompt_message=message, - error=str(exc), - error_type=type(exc).__name__, - consequence="Message not displayed", - ) - return r[bool].fail(error_message_template.format(error=exc)) + # Fail loud: a logger failure propagates with its cause. + self._log(log_level, message_format.format(message=message)) + return r[bool].ok(True) def _read_confirmation_input( self, message: str, prompt_text: str, *, default: bool diff --git a/src/flext_cli/services/prompts.py b/src/flext_cli/services/prompts.py index 693c17237..3a093b4f6 100644 --- a/src/flext_cli/services/prompts.py +++ b/src/flext_cli/services/prompts.py @@ -89,7 +89,6 @@ def print_error(self, message: str) -> p.Result[bool]: message, c.LogLevel.ERROR, c.Cli.PROMPT_ERROR_FMT, - "Print error failed: {error}", ) def print_success(self, message: str) -> p.Result[bool]: @@ -98,7 +97,6 @@ def print_success(self, message: str) -> p.Result[bool]: message, c.LogLevel.INFO, c.Cli.PROMPT_SUCCESS_FMT, - "Print success failed: {error}", ) def print_warning(self, message: str) -> p.Result[bool]: @@ -107,7 +105,6 @@ def print_warning(self, message: str) -> p.Result[bool]: message, c.LogLevel.WARNING, c.Cli.PROMPT_WARNING_FMT, - "Print warning failed: {error}", ) def _read_prompt_value(self, message: str, default: str) -> str: diff --git a/tests/unit/test_model_command_json_options.py b/tests/unit/test_model_command_json_options.py new file mode 100644 index 000000000..aeb432d65 --- /dev/null +++ b/tests/unit/test_model_command_json_options.py @@ -0,0 +1,153 @@ +"""Behavioral tests for JSON-carried options of ``cli.model_command``. + +A model field without a native CLI form (a mapping, a nested model, or a +collection of them) is exposed as one option carrying JSON text that Pydantic +validates into the field's declared type. Every assertion registers the +generated command in a real app and observes it through ``cli.invoke_app`` or +``cli.execute_app``; nothing is patched or introspected. +""" + +from __future__ import annotations + +import pytest +from flext_tests import tm + +from flext_cli import c, cli +from tests import m, p, t, u + + +class TestsFlextCliModelCommandJsonOptions: + """Mapping and nested-model fields travel as validated JSON options.""" + + class MappingModel(m.BaseModel): + """Request whose only field is a required mapping.""" + + labels: dict[str, int] + + class NestedModel(m.BaseModel): + """Request carrying a nested model and a sequence of nested models.""" + + prefs: m.Tests.UserPreferences + others: list[m.Tests.UserPreferences] = m.Field(default_factory=list) + + class JsonDefaultsModel(m.BaseModel): + """Request whose JSON options all carry defaults.""" + + labels: t.MappingKV[str, int] = m.Field( + default_factory=lambda: {"seed": 1}, description="Label weights." + ) + prefs: m.Tests.UserPreferences = m.Field( + default_factory=lambda: m.Tests.UserPreferences( + theme="light", notifications=False + ) + ) + + @staticmethod + def _app[M: t.Cli.ModelLike]( + model_cls: t.ModelClass[M], + received: list[M], + *, + settings: t.Cli.ModelLike | None = None, + ) -> p.Cli.Application: + """Register the model command in a real app that records its input.""" + + def _capture(params: M) -> bool: + received.append(params) + return True + + app = cli.create_app_with_common_params(name="json-app", help_text="JSON app") + cli.register_command( + app, + name="run", + help_text="Run", + command=cli.model_command(model_cls, _capture, settings=settings), + ) + return app + + def _invoke[M: t.Cli.ModelLike]( + self, + model_cls: t.ModelClass[M], + args: t.StrSequence, + *, + settings: t.Cli.ModelLike | None = None, + ) -> tuple[m.Cli.InvocationResult, list[M]]: + received: list[M] = [] + app = self._app(model_cls, received, settings=settings) + invocation = cli.invoke_app(app, args=["run", *args]) + tm.ok(invocation) + return invocation.value, received + + @staticmethod + def _json(value: t.JsonPayload) -> str: + return u.to_json(value).decode() + + def test_mapping_option_json_reaches_model(self) -> None: + """A JSON object passed to a mapping option is the model's mapping.""" + expected = self.MappingModel(labels={"alpha": 1, "beta": 2}) + invocation, received = self._invoke( + self.MappingModel, ["--labels", self._json(expected.labels)] + ) + tm.that(u.Cli.process_succeeded(invocation.outcome), eq=True) + tm.that(received, eq=[expected]) + + def test_nested_model_options_json_reach_model(self) -> None: + """Nested models and their sequences validate from JSON options.""" + expected = self.NestedModel( + prefs=m.Tests.UserPreferences(theme="dark", notifications=True), + others=[m.Tests.UserPreferences(theme="solar", notifications=False)], + ) + invocation, received = self._invoke( + self.NestedModel, + [ + "--prefs", + self._json(expected.prefs), + "--others", + self._json(expected.others), + ], + ) + tm.that(u.Cli.process_succeeded(invocation.outcome), eq=True) + tm.that(received, eq=[expected]) + + def test_omitted_json_options_deliver_model_defaults(self) -> None: + """Omitted JSON options deliver the model's own defaults.""" + invocation, received = self._invoke(self.JsonDefaultsModel, []) + tm.that(u.Cli.process_succeeded(invocation.outcome), eq=True) + tm.that(received, eq=[self.JsonDefaultsModel()]) + + def test_omitted_json_options_take_settings_values(self) -> None: + """A settings model seeds omitted JSON options with its values.""" + settings = self.JsonDefaultsModel( + labels={"from-settings": 7}, + prefs=m.Tests.UserPreferences(theme="settings", notifications=True), + ) + invocation, received = self._invoke( + self.JsonDefaultsModel, [], settings=settings + ) + tm.that(u.Cli.process_succeeded(invocation.outcome), eq=True) + tm.that(received, eq=[settings]) + + @pytest.mark.parametrize( + ("raw", "error_type"), + [("{not json", "json_invalid"), ('{"alpha": "one"}', "int_parsing")], + ) + def test_invalid_json_option_fails_loud_with_cause( + self, raw: str, error_type: str + ) -> None: + """Malformed JSON or a schema mismatch fails before the handler runs.""" + invocation, received = self._invoke(self.MappingModel, ["--labels", raw]) + tm.that(u.Cli.process_succeeded(invocation.outcome), eq=False) + tm.that(received, empty=True) + + executed: list[TestsFlextCliModelCommandJsonOptions.MappingModel] = [] + app = self._app(self.MappingModel, executed) + result = cli.execute_app(app, prog_name="json-app", args=["run", "--labels", raw]) + tm.fail(result, has=error_type) + tm.that(executed, empty=True) + + def test_help_renders_json_options(self) -> None: + """Help renders every JSON option with the JSON format marker.""" + invocation, received = self._invoke(self.JsonDefaultsModel, ["--help"]) + tm.that(u.Cli.process_succeeded(invocation.outcome), eq=True) + tm.that(invocation.stdout, has=["--labels", "--prefs"]) + tm.that(invocation.stdout, has=c.Cli.CLI_JSON_OPTION_METAVAR) + tm.that(received, empty=True) diff --git a/tests/unit/test_options_public_cov.py b/tests/unit/test_options_public_cov.py index fccbcde60..a266c463c 100644 --- a/tests/unit/test_options_public_cov.py +++ b/tests/unit/test_options_public_cov.py @@ -29,7 +29,7 @@ class _OptionSettings(m.BaseModel): @pytest.mark.parametrize( ("annotation", "expected"), - [(str | None, str), (str | int, str), (dict[str, int], dict), (int, int)], + [(str | None, str), (str | int, str), (dict[str, int], str), (int, int)], ) def test_resolve_typer_annotation_maps_scalars_unions_and_collections( self, annotation: t.Cli.RuntimeAnnotation, expected: type @@ -102,13 +102,16 @@ def test_field_default_normalizes_sequence_field_to_tuple(self) -> None: eq=("lint", "typecheck"), ) - def test_field_default_normalizes_mapping_field_preserving_entries(self) -> None: - """Verify that field default normalizes mapping field preserving entries.""" + def test_field_default_renders_mapping_field_as_json_text(self) -> None: + """A mapping default is the JSON text that decodes to the same entries.""" settings = self._OptionSettings() fields = self._OptionSettings.model_fields + default = u.Cli.field_default("flags", fields["flags"], settings) + + tm.that(isinstance(default, str), eq=True) tm.that( - u.Cli.field_default("flags", fields["flags"], settings), eq={"debug": True} + 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: