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
6 changes: 3 additions & 3 deletions src/flext_cli/_utilities/_json/_navigate.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,9 @@ def json_pick_int(data: t.JsonMapping, key: str, default: int = 0) -> int:
@staticmethod
def json_pick_bool(data: t.JsonMapping, key: str, *, default: bool = False) -> bool:
"""Extract a boolean value from mapping with string/int coercion."""
parsed: bool = u.parse(
data.get(key, None), bool, default=default
).unwrap_or(default)
parsed: bool = u.parse(data.get(key, None), bool, default=default).unwrap_or(
default
)
return parsed

@staticmethod
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,14 @@ def resolve_typer_annotation(
get_origin(t.StrSequence),
t.SequenceOf,
t.MutableSequenceOf,
# Typer has no set type: sets are repeated options whose
# list the request model validates into set/frozenset.
get_origin(set[str]),
get_origin(frozenset[str]),
],
)
)
# Why: get_origin() on these concrete generics never returns None;
# the prior None-filter was dead code (pyright reportUnnecessaryComparison).
set_origins: dict[object, type] = {
get_origin(dict[str, t.Scalar]): dict,
get_origin(frozenset[str]): frozenset,
get_origin(set[str]): set,
}
mapping_origin = get_origin(dict[str, t.Scalar])
resolved_annotation_input = annotation
origin = get_origin(resolved_annotation_input)
while (
Expand Down Expand Up @@ -74,9 +72,8 @@ def resolve_typer_annotation(
sequence_item = resolved_inner if isinstance(resolved_inner, type) else str
return GenericAlias(list, (sequence_item,))

set_annotation = set_origins.get(origin)
if set_annotation is not None:
return set_annotation
if origin == mapping_origin:
return dict

return (
resolved_annotation_input
Expand Down
4 changes: 3 additions & 1 deletion src/flext_cli/services/_cli_parts/flextclicli_part_01.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ def __init__(
self._model_cls = model_cls

def __call__(self, **kwargs: t.Cli.CliValue) -> t.JsonValue:
model = self._model_cls.model_validate(kwargs)
# 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)
return self._handler(model)

@classmethod
Expand Down
39 changes: 25 additions & 14 deletions src/flext_cli/services/_prompts_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,27 @@ class FlextCliPromptsSupport(s[m.Cli.RuntimeStatus]):
m.Field(description="Prompt runtime state for interaction behavior."),
] = m.Field(m.Cli.PromptRuntimeState(), validate_default=True)

_input_reader: t.Cli.PromptTextReader = m.PrivateAttr(_PromptInputReaderDefault())

_password_reader: t.Cli.PromptTextReader = m.PrivateAttr(
_PromptPasswordReaderDefault()
)

_test_env_override: bool | None = m.PrivateAttr(None)
input_reader: Annotated[
t.Cli.PromptTextReader,
m.Field(
description=(
"Text input port; reads the process stdin unless the embedding "
"application injects its own source."
),
exclude=True,
),
] = m.Field(default_factory=_PromptInputReaderDefault, validate_default=True)

password_reader: Annotated[
t.Cli.PromptTextReader,
m.Field(
description=(
"Secret input port; reads the terminal without echo unless the "
"embedding application injects its own source."
),
exclude=True,
),
] = m.Field(default_factory=_PromptPasswordReaderDefault, validate_default=True)

def configure(self, state: m.Cli.PromptRuntimeState) -> Self:
"""Replace prompt runtime state using the canonical CLI model."""
Expand All @@ -56,13 +70,10 @@ def configure(self, state: m.Cli.PromptRuntimeState) -> Self:
def _is_test_env(self) -> bool:
"""Whether prompt logging must use test-safe behavior.

The override private attr wins when set (tests pin it via
``override_test_env``); otherwise delegate to the canonical
``u.Cli.cli_test_env`` utility — settings stay pure flat data (§2.6),
detection logic lives in the utilities layer, never reimplemented here.
Delegates to the canonical ``u.Cli.cli_test_env`` utility — settings
stay pure flat data (§2.6), detection logic lives in the utilities
layer, never reimplemented here.
"""
if self._test_env_override is not None:
return self._test_env_override
return u.Cli.cli_test_env(settings)

def _guarded[TResult](
Expand Down Expand Up @@ -141,7 +152,7 @@ def _read_confirmation_input(
self, message: str, prompt_text: str, *, default: bool
) -> p.Result[bool]:
while True:
input_text = self._input_reader(prompt_text)
input_text = self.input_reader(prompt_text)
parsed = u.Cli.prompts_parse_confirmation(input_text, default=default)
if parsed is not None:
return r[bool].ok(parsed)
Expand Down
4 changes: 2 additions & 2 deletions src/flext_cli/services/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def prompt_password(
"prompt_password",
message,
lambda: u.Cli.prompts_password_result(
self._password_reader(f"{message}{c.Cli.PROMPT_SPACE}"),
self.password_reader(f"{message}{c.Cli.PROMPT_SPACE}"),
min_length=min_length,
),
consequence="Password prompt failed completely",
Expand Down Expand Up @@ -113,7 +113,7 @@ def print_warning(self, message: str) -> p.Result[bool]:
def _read_prompt_value(self, message: str, default: str) -> str:
"""Read one prompt value and record the canonical prompt log."""
display_message = u.Cli.prompts_display_message(message, default)
raw = self._input_reader(f"{display_message}{c.Cli.PROMPT_SEP}")
raw = self.input_reader(f"{display_message}{c.Cli.PROMPT_SEP}")
value: str = u.Cli.prompts_effective_text(raw, default)
if not self._is_test_env():
self._log(
Expand Down
8 changes: 0 additions & 8 deletions tests/_constants_parts/tests_rules_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,6 @@ class TestsFlextCliConstantsRulesOptions:
"file-lint": (RULES_MAPPING_MATCHER,)
})

# ── OPTIONS (utilities/options.py) ─────────────────────────────
OPTIONS_FIELD_DEFAULT_VALID_MAPPING: ClassVar[t.Cli.DefaultMapping] = (
MappingProxyType({"name": "alpha", "count": 3, "tags": ("x", "y")})
)
OPTIONS_FIELD_DEFAULT_INVALID_MAPPING: ClassVar[t.JsonMapping] = {
"nested": {"ignore": True}
}

# ── TOML ───────────────────────────────────────────────────────
TOML_VALID_CONTENT: ClassVar[str] = (
f'[tool.flext]\nproject = "my-project"\nversion = "{c.Cli.CLI_VERSION}"\n'
Expand Down
23 changes: 23 additions & 0 deletions tests/_models_parts/tests_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from pathlib import Path
from typing import Annotated

from flext_cli import m
Expand Down Expand Up @@ -59,5 +60,27 @@ class ImportRecord(m.BaseModel):
name: Annotated[str, m.Field(description="Record name")]
value: Annotated[str, m.Field(description="Record value")]

class SummaryStats(m.FrozenModel):
"""Orchestration summary payload satisfying ``p.Cli.SummaryStats``."""

verb: Annotated[str, m.Field(description="Verb label")]
total: Annotated[int, m.Field(description="Total processed items")]
success: Annotated[int, m.Field(description="Successful items")]
failed: Annotated[int, m.Field(description="Failed items")]
skipped: Annotated[int, m.Field(description="Skipped items")]
elapsed: Annotated[float, m.Field(description="Elapsed seconds")]

class ProjectFailureInfo(m.FrozenModel):
"""Per-project failure payload satisfying ``p.Cli.ProjectFailureInfo``."""

project: Annotated[str, m.Field(description="Project name")]
elapsed: Annotated[float, m.Field(description="Elapsed seconds")]
error_count: Annotated[int, m.Field(description="Total project errors")]
log_path: Annotated[Path, m.Field(description="Project log path")]
max_show: Annotated[int, m.Field(description="Maximum errors rendered")]
errors: Annotated[
tuple[str, ...], m.Field(description="Rendered error excerpt lines")
]


__all__: list[str] = ["TestsFlextCliModelsCli"]
62 changes: 3 additions & 59 deletions tests/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,13 @@

from __future__ import annotations

from typing import TYPE_CHECKING, Protocol, Self
from typing import TYPE_CHECKING, Protocol

from flext_tests import FlextTestsProtocols

from flext_cli import FlextCliProtocols

if TYPE_CHECKING:
from types import EllipsisType

from tests import m, t


Expand All @@ -24,34 +22,8 @@ class TestsFlextCliProtocols(FlextTestsProtocols, FlextCliProtocols):
class Tests(FlextTestsProtocols.Tests):
"""Test-specific protocols."""

class ScriptedPrompts(Protocol):
"""Prompt test double contract exposed through the canonical `p`."""

def override_test_env(self, *, enabled: bool | None = True) -> Self:
"""Define the override test env test contract."""
...

def use_input_values(self, values: t.StrSequence) -> Self:
"""Define the use input values test contract."""
...

def use_input_error(self, error: Exception) -> Self:
"""Define the use input error test contract."""
...

def use_password(self, password: str) -> Self:
"""Define the use password test contract."""
...

def use_password_error(self, error: Exception) -> Self:
"""Define the use password error test contract."""
...

def configure_state(
self, *, interactive: bool = True, quiet: bool = False
) -> Self:
"""Define the configure state test contract."""
...
class Prompts(Protocol):
"""Public prompt service surface exercised by the prompt tests."""

def execute(self) -> p.Result[m.Cli.RuntimeStatus]:
"""Define the execute test contract."""
Expand Down Expand Up @@ -89,34 +61,6 @@ def print_warning(self, message: str) -> p.Result[bool]:
"""Define the print warning test contract."""
...

class FrameworkOption(Protocol):
"""Typed option metadata exposed in a generated command signature."""

@property
def param_decls(self) -> t.StrSequence | None:
"""Ordered framework option declarations."""
...

@property
def default(self) -> t.Cli.CliValue | EllipsisType | None:
"""Generated option default."""
...

class CaptureLogPrompts(ScriptedPrompts, Protocol):
"""Prompt test double that exposes captured log records."""

@property
def records(self) -> list[tuple[str, str]]:
"""Define the records test contract."""
...

class FailingLogPrompts(ScriptedPrompts, Protocol):
"""Prompt test double that can fail a selected log call."""

def fail_on_log(self, *, level: str, message: str) -> Self:
"""Define the fail on log test contract."""
...


p = TestsFlextCliProtocols
__all__: list[str] = ["TestsFlextCliProtocols", "p"]
Original file line number Diff line number Diff line change
Expand Up @@ -46,25 +46,28 @@ def test_create_app_with_common_params_handles_invalid_trace_without_debug(
"""Keep trace disabled when debug is not enabled at the public boundary."""
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

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(settings.trace, eq=False)
tm.that(settings.trace, eq=trace_before)

def test_create_app_with_common_params_no_flags_keeps_settings(self) -> None:
"""Preserve settings when the invocation supplies no shared flags."""
app = cli.create_app_with_common_params(
name="identity-app", help_text="Identity app"
)
cli.register_command(app, name="ok", help_text="OK", command=lambda: True)
shared_flags = {"debug", "trace", "verbose", "quiet", "log_level"}
flags_before = settings.model_dump(include=shared_flags)

invoke_result = cli.invoke_app(app, args=["ok"])

tm.ok(invoke_result)
tm.that(u.Cli.process_succeeded(invoke_result.value.outcome), eq=True)
tm.that(settings.debug, eq=False)
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."""
Expand Down
Loading
Loading