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
7 changes: 7 additions & 0 deletions src/flext_cli/_constants/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
5 changes: 5 additions & 0 deletions src/flext_cli/_models/_base/flextclimodelsbase_part_03.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
2 changes: 0 additions & 2 deletions src/flext_cli/_typings/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 0 additions & 3 deletions src/flext_cli/_utilities/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -258,7 +257,6 @@
"FlextCliUtilitiesJsonCoreMixin",
"FlextCliUtilitiesJsonNavigateMixin",
"FlextCliUtilitiesMatching",
"FlextCliUtilitiesModelCommands",
"FlextCliUtilitiesOptionBuilder",
"FlextCliUtilitiesOptions",
"FlextCliUtilitiesOutput",
Expand Down Expand Up @@ -648,7 +646,6 @@
".framework": ("FlextCliUtilitiesFramework",),
".json": ("FlextCliUtilitiesJson",),
".matching": ("FlextCliUtilitiesMatching",),
".model_commands": ("FlextCliUtilitiesModelCommands",),
".output": ("FlextCliUtilitiesOutput",),
".params": ("FlextCliUtilitiesParams",),
".pipeline": ("FlextCliUtilitiesPipeline",),
Expand Down
2 changes: 0 additions & 2 deletions src/flext_cli/_utilities/_cli_namespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,7 +48,6 @@ class FlextCliUtilitiesCli(
FlextCliUtilitiesFramework,
FlextCliUtilitiesJson,
FlextCliUtilitiesMatching,
FlextCliUtilitiesModelCommands,
FlextCliUtilitiesOptions,
FlextCliUtilitiesOutput,
FlextCliUtilitiesParams,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
36 changes: 0 additions & 36 deletions src/flext_cli/_utilities/model_commands.py

This file was deleted.

4 changes: 2 additions & 2 deletions src/flext_cli/services/_cli_parts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
30 changes: 26 additions & 4 deletions src/flext_cli/services/_cli_parts/flextclicli_part_01.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -28,25 +29,46 @@ class _ModelCommand[M: t.Cli.ModelLike]:
__signature__: Signature
_handler: p.Cli.ModelCommandHandler[M]
_model_cls: t.ModelClass[M]
_result_border: bool

def __init__(
self,
*,
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
Expand All @@ -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
Expand Down
19 changes: 10 additions & 9 deletions src/flext_cli/services/_cli_parts/flextclicli_part_02.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,14 +22,14 @@ 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:
u.fetch_logger(__name__).warning(
"failed to apply cli params", error=result.error or ""
)
else:
self._apply_updated_settings(result.value)
next_params = m.Cli.CliParamsConfig.model_validate({
**params.model_dump(),
"log_level": resolved_log_level,
})
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:
Expand Down Expand Up @@ -86,6 +86,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)
Expand Down
55 changes: 28 additions & 27 deletions src/flext_cli/services/_cli_parts/flextclicli_part_03.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,41 +28,42 @@ 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
)
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,
Expand All @@ -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"]
Loading
Loading