Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/flext_cli/_constants/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions src/flext_cli/_typings/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
Expand All @@ -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
Expand Down
26 changes: 24 additions & 2 deletions src/flext_cli/_utilities/framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 12 additions & 5 deletions src/flext_cli/services/_cli_parts/flextclicli_part_01.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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,
)

Expand Down
19 changes: 3 additions & 16 deletions src/flext_cli/services/_prompts_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 0 additions & 3 deletions src/flext_cli/services/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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]:
Expand All @@ -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:
Expand Down
Loading
Loading