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
5 changes: 0 additions & 5 deletions src/flext_cli/_constants/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,6 @@ class FlextCliConstantsBase:
OP_EXECUTE_APPLICATION: ClassVar[str] = "execute CLI application"
ERR_EXIT_WITH_CODE: ClassVar[str] = "CLI exited with code {exit_code}"

CLI_SAFE_EXCEPTIONS: ClassVar[t.VariadicTuple[type[Exception]]] = (
ValueError,
TypeError,
KeyError,
)
# Pipeline retries fail loud by default; the bound prevents accidental
# exponential fan-out when a caller declares a retry policy.
PIPELINE_DEFAULT_RETRY: ClassVar[int] = 0
Expand Down
3 changes: 0 additions & 3 deletions src/flext_cli/_constants/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,6 @@ class FlextCliConstantsErrors:
ERR_INVALID_CREDENTIALS: ClassVar[str] = (
"Invalid credentials: missing token or username/password"
)
ERR_SETTINGS_VALIDATION_FAILED: ClassVar[str] = (
"Settings validation failed: {error}"
)
ERR_AUTH_SAVE_FAILED: ClassVar[str] = "Failed to save token: {error}"
ERR_AUTH_LOAD_FAILED: ClassVar[str] = "Failed to load token: {error}"
ERR_AUTH_FILE_NOT_FOUND: ClassVar[str] = "Token file does not exist"
Expand Down
1 change: 0 additions & 1 deletion src/flext_cli/_constants/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ class FlextCliConstantsOutput:
)
OUTPUT_TABLE_NORMALIZATION_FAILED: ClassVar[str] = "Table normalization failed"
OUTPUT_TABLE_ROW_INVALID: ClassVar[str] = "Table row invalid after validation"
OUTPUT_TABLE_FORMATTING_OPERATION: ClassVar[str] = "Table formatting"

TABLE_FORMATS: ClassVar[t.StrMapping] = MappingProxyType({
ce.TabularFormat.PLAIN: "Minimal formatting, no borders",
Expand Down
1 change: 0 additions & 1 deletion src/flext_cli/_typings/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ class FlextCliTypesDomain:
type SuccessMessageFormatter[TResult: ResultValue = ResultValue] = Callable[
[TResult], str
]
type MappingProcessor[T, U] = Callable[[str, T], U]
type TomlMappingSource = (
t.JsonPayload | t.JsonMapping | t.ScalarMapping | Item | TOMLDocument
)
Expand Down
15 changes: 6 additions & 9 deletions src/flext_cli/_utilities/cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,12 @@ def cmd_show_settings(logger: p.Logger) -> p.Result[bool]:
@staticmethod
def cmd_validate_settings(logger: p.Logger) -> p.Result[bool]:
"""Validate canonical settings structure and log normalized results."""
try:
results = us.validate_settings_structure()
if results:
logger.info(
c.Cli.LOG_MSG_SETTINGS_VALIDATION_RESULTS.format(results=results)
)
return r[bool].ok(True)
except c.Cli.CLI_SAFE_EXCEPTIONS as exc:
return r[bool].fail(c.Cli.ERR_SETTINGS_VALIDATION_FAILED.format(error=exc))
results = us.validate_settings_structure()
if results:
logger.info(
c.Cli.LOG_MSG_SETTINGS_VALIDATION_RESULTS.format(results=results)
)
return r[bool].ok(True)


__all__: t.MutableSequenceOf[str] = ["FlextCliUtilitiesCmd"]
39 changes: 20 additions & 19 deletions src/flext_cli/_utilities/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,22 +39,26 @@ def tables_resolve_config(
settings: m.Cli.TableConfig | None = None,
**settings_kwargs: t.Cli.TableConfigValue,
) -> p.Result[m.Cli.TableConfig]:
"""Resolve table config via canonical Pydantic model contract."""
"""Resolve table config via canonical Pydantic model contract.

An override the model rejects is the declared failure outcome and the
result carries the ``ValidationError``; every other exception escapes.
"""
if settings is not None and not settings_kwargs:
return r[m.Cli.TableConfig].ok(settings)
base_data = (
settings.model_dump(exclude_computed_fields=True)
if settings is not None
else {}
)
settings_data = {**base_data, **settings_kwargs}
try:
if settings is not None and not settings_kwargs:
return r[m.Cli.TableConfig].ok(settings)
base_data = (
settings.model_dump(exclude_computed_fields=True)
if settings is not None
else {}
)
settings_data = {**base_data, **settings_kwargs}
resolved = m.Cli.TableConfig.model_validate(settings_data)
return r[m.Cli.TableConfig].ok(resolved)
except c.Cli.CLI_SAFE_EXCEPTIONS as exc:
except c.ValidationError as exc:
return r[m.Cli.TableConfig].fail(
c.Cli.OUTPUT_TABLE_CONFIG_INVALID_FMT.format(error=exc)
c.Cli.OUTPUT_TABLE_CONFIG_INVALID_FMT.format(error=exc), exception=exc
)
return r[m.Cli.TableConfig].ok(resolved)

@staticmethod
def tables_normalize_data(
Expand Down Expand Up @@ -140,13 +144,10 @@ def tables_render(
if colalign is not None and column_count > 0 and len(colalign) > column_count:
colalign = colalign[:column_count]

try:
rendered_table = FlextCliUtilitiesTablesRenderer.render(
rows, headers, colalign=colalign or (), settings=settings
)
return r[str].ok(rendered_table)
except c.Cli.CLI_SAFE_EXCEPTIONS as exc:
return r[str].fail_op(c.Cli.OUTPUT_TABLE_FORMATTING_OPERATION, exc)
rendered_table = FlextCliUtilitiesTablesRenderer.render(
rows, headers, colalign=colalign or (), settings=settings
)
return r[str].ok(rendered_table)


__all__: t.MutableSequenceOf[str] = ["FlextCliUtilitiesTables"]
33 changes: 0 additions & 33 deletions src/flext_cli/_utilities/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,47 +2,14 @@

from __future__ import annotations

from collections.abc import MutableMapping
from typing import ClassVar

from flext_cli import c, m, p, r, t
from flext_core import u

PydanticValidationError = m.ValidationError


class FlextCliUtilitiesValidation:
"""Validation methods exposed directly on ``u.Cli``."""

_module_logger: ClassVar[p.Logger] = u.fetch_logger(__name__)

@staticmethod
def process_mapping[T, U](
items: t.MappingKV[str, T],
processor: t.Cli.MappingProcessor[T, U],
on_error: str = "fail",
) -> p.Result[t.MappingKV[str, U]]:
"""Process a mapping of items with canonical error handling."""
errors: t.MutableSequenceOf[str] = []
values: MutableMapping[str, U] = {}
for key, value in items.items():
try:
values[key] = processor(key, value)
except c.Cli.CLI_SAFE_EXCEPTIONS as exc:
if on_error == "fail":
return r[t.MappingKV[str, U]].fail(f"Error processing {key}: {exc}")
if on_error == "collect":
errors.append(f"{key}: {exc}")
else:
FlextCliUtilitiesValidation._module_logger.debug(
f"process_mapping skip key {key}: {exc}", exc_info=False
)
return (
r[t.MappingKV[str, U]].fail("; ".join(errors))
if errors
else r[t.MappingKV[str, U]].ok(values)
)

@staticmethod
def validate_not_empty(
val: t.Cli.CliValue | None, *, name: str = "field"
Expand Down
11 changes: 7 additions & 4 deletions src/flext_cli/services/cli_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,18 @@ def apply_to_config(
"""Apply CLI parameter values to FlextSettings using Pydantic validation.

Business Rule: Applies CLI parameter values with Pydantic validation.
Trace mode requires debug mode to be enabled.
Trace mode requires debug mode to be enabled. Parameter values the
``CliParamsConfig`` model rejects are the declared failure outcome and
the result carries the ``ValidationError``; every other exception
escapes.
"""
try:
params_to_use = u.Cli.params_resolve(params, kwargs)
return u.Cli.params_apply(settings, params_to_use)
except c.Cli.CLI_SAFE_EXCEPTIONS as exc:
except c.ValidationError as exc:
return r[p.Cli.Settings].fail(
c.Cli.CLI_PARAM_ERR_APPLY_FAILED_FMT.format(error=exc)
c.Cli.CLI_PARAM_ERR_APPLY_FAILED_FMT.format(error=exc), exception=exc
)
return u.Cli.params_apply(settings, params_to_use)

@classmethod
def create_option(cls, field_name: str) -> m.Cli.OptionSpec:
Expand Down
10 changes: 10 additions & 0 deletions tests/unit/test_cli_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,16 @@ def test_apply_to_config_trace_without_debug_fails(
tm.fail(result)
tm.that((result.error or "").lower(), has="trace mode requires debug mode")

def test_apply_to_config_unknown_parameter_fails_carrying_validation_error(
self, settings: p.Cli.Settings
) -> None:
"""A parameter the params model rejects fails and keeps its cause."""
result = cli.apply_to_config(settings, not_a_param=True)

tm.fail(result)
tm.that((result.error or ""), has="not_a_param")
tm.that(result.exception, is_=c.ValidationError)

@pytest.mark.parametrize(
("field_name", "expected_fragments"),
[
Expand Down
46 changes: 0 additions & 46 deletions tests/unit/test_cmd_runtime_validation_branch_cov.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,11 @@

from __future__ import annotations

from collections.abc import Callable

import pytest
from flext_tests import tm

from tests import c, m, t, u

type MappingProcessor = Callable[[str, int], int]


def _raise_bad(_key: str, _value: int) -> int:
"""Processor that always raises to drive the error-handling branches."""
msg = "bad-item"
raise ValueError(msg)


class TestsFlextCliCmdRuntimeValidationBranchCov:
"""Public-contract behavior of cmd, runtime, and validation helpers."""
Expand Down Expand Up @@ -81,42 +71,6 @@ def test_process_env_removing_unknown_key_is_a_noop(self) -> None:
tm.that(pruned, eq=baseline)

# ---------------------------------------------------------- validation
def test_process_mapping_maps_every_value_on_success(self) -> None:
"""Verify that process mapping maps every value on success."""
result = u.Cli.process_mapping({"a": 1, "b": 2}, lambda _key, value: value * 10)

tm.ok(result)
tm.that(result.unwrap(), eq={"a": 10, "b": 20})

def test_process_mapping_fail_mode_stops_at_first_error(self) -> None:
"""Verify that process mapping fail mode stops at first error."""
result = u.Cli.process_mapping({"a": 1}, _raise_bad, on_error="fail")

tm.fail(result)
tm.that((result.error or ""), has="a")
tm.that((result.error or ""), has="bad-item")

def test_process_mapping_collect_mode_aggregates_errors(self) -> None:
"""Verify that process mapping collect mode aggregates errors."""
result = u.Cli.process_mapping({"a": 1}, _raise_bad, on_error="collect")

tm.fail(result)
tm.that((result.error or ""), has="a: bad-item")

def test_process_mapping_skip_mode_yields_only_successful_items(self) -> None:
"""Verify that process mapping skip mode yields only successful items."""

def raise_on_a(key: str, value: int) -> int:
if key == "a":
msg = "boom"
raise ValueError(msg)
return value

result = u.Cli.process_mapping({"a": 1, "b": 2}, raise_on_a, on_error="skip")

tm.ok(result)
tm.that(result.unwrap(), eq={"b": 2})

@pytest.mark.parametrize("output_format", tuple(c.Cli.OUTPUT_FORMATS))
def test_validate_format_accepts_every_supported_format(
self, output_format: str
Expand Down
19 changes: 9 additions & 10 deletions tests/unit/test_runtime_process_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import linecache
import sys
import threading
import time
from pathlib import Path
from types import FrameType
from typing import TYPE_CHECKING
Expand All @@ -21,11 +20,14 @@
class TestsRuntimeProcessCompletion:
"""A completed child must not wait for its execution deadline."""

def test_completion_before_wake_clear_returns_promptly(
self, tmp_path: Path
) -> None:
def test_completion_before_wake_clear_ends_monitoring(self, tmp_path: Path) -> None:
"""Schedule a real waiter at the exact lost-notification boundary.

The trace holds the monitor at its first ``wake.clear()`` until the
root waiter has set both ``process_done`` and ``wake``, so completion
is always observed before the wake is cleared. A monitor that lost
that notification would sleep until its deadline and report
``timed_out``; the outcome, not the host clock, proves it did not.
Tracing only controls thread scheduling; process creation, events,
waiting, output and cleanup all use the unmodified public runtime.
"""
Expand All @@ -43,26 +45,23 @@ def trace(frame: FrameType, event: str, _argument: object) -> TraceFunction:
wake = frame.f_locals["wake"]
assert isinstance(done, threading.Event)
assert isinstance(wake, threading.Event)
assert done.wait(2)
assert wake.wait(2)
done.wait()
wake.wait()
scheduled.append(True)
return trace

previous = sys.gettrace()
started = time.monotonic()
try:
sys.settrace(trace)
result = u.Cli().run_to_file(
[sys.executable, "-c", "import time;time.sleep(.1);print('complete')"],
[sys.executable, "-I", "-S", "-c", "print('complete')"],
tmp_path / "completion.log",
timeout=4,
)
finally:
sys.settrace(previous)
elapsed = time.monotonic() - started
outcome = tm.ok(result)
assert scheduled == [True]
assert outcome.raw_return_code == 0
assert not outcome.timed_out
assert elapsed < 2
assert (tmp_path / "completion.log").read_text() == "complete\n"
34 changes: 16 additions & 18 deletions tests/unit/test_runtime_process_descendants.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,28 +41,30 @@ class TestsFlextCliRuntimeProcessDescendants:
"""Prove root completion is not mistaken for boundary completion."""

def test_normal_root_exit_leaves_no_descendant(self, tmp_path: Path) -> None:
"""A descendant alive when the root exits is gone when the run returns.

The descendant announces its identity over a pipe and then blocks
forever; the root reads that line (a blocking read, no polling),
records it, and exits. Only containment can end the descendant, so
its absence from the process table after ``run_to_file`` returns is
the observable proof.
"""
output_file = tmp_path / "normal-exit.log"
heartbeat = tmp_path / "normal-heartbeat"
process_info = tmp_path / "normal-process-info"
child = (
"import os,pathlib,sys,time;"
"path=pathlib.Path(sys.argv[1]);"
"group=getattr(os,'getpgrp',lambda:0)();"
"pathlib.Path(sys.argv[2]).write_text(f'{os.getpid()} {group}');"
"\nwhile True:\n path.write_text(str(time.monotonic()));time.sleep(.02)"
"import os,threading;"
"print(os.getpid(),getattr(os,'getpgrp',lambda:0)(),flush=True);"
"threading.Event().wait()"
)
parent = (
"import pathlib,subprocess,sys,time;"
f"subprocess.Popen([sys.executable,'-c',{child!r},"
"sys.argv[1],sys.argv[2]]);"
"heartbeat=pathlib.Path(sys.argv[1]);"
"info=pathlib.Path(sys.argv[2]);"
"\nwhile not heartbeat.exists() or not info.exists():\n time.sleep(.01)"
"import pathlib,subprocess,sys;"
f"child=subprocess.Popen([sys.executable,'-I','-S','-c',{child!r}],"
"stdout=subprocess.PIPE);"
"pathlib.Path(sys.argv[1]).write_bytes(child.stdout.readline())"
)
started = time.monotonic()

result = u.Cli().run_to_file(
[sys.executable, "-c", parent, str(heartbeat), str(process_info)],
[sys.executable, "-I", "-S", "-c", parent, str(process_info)],
output_file,
)

Expand All @@ -71,14 +73,10 @@ def test_normal_root_exit_leaves_no_descendant(self, tmp_path: Path) -> None:
child_pid, process_group = (
int(value) for value in process_info.read_text().split()
)
stopped_value = heartbeat.stat().st_mtime_ns
time.sleep(0.15)
tm.that(heartbeat.stat().st_mtime_ns, eq=stopped_value)
tm.that(_process_exists(child_pid), eq=False)
if os.name != "nt":
with pytest.raises(ProcessLookupError):
os.killpg(process_group, 0)
tm.that(time.monotonic() - started, lt=5.0)

def test_windows_job_reports_zero_active_processes(self, tmp_path: Path) -> None:
_ObservedWindowsCli.active_counts.clear()
Expand Down
1 change: 1 addition & 0 deletions tests/unit/test_tables_cov.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ def test_resolve_config_invalid_kwarg_fails_with_config_message(self) -> None:
result = u.Cli.tables_resolve_config(not_a_field="oops")
tm.fail(result)
tm.that((result.error or ""), has="Invalid table configuration")
tm.that(result.exception, is_=c.ValidationError)

# ── tables_normalize_data ─────────────────────────────────────────

Expand Down
Loading
Loading