Skip to content

fix(cli): mapping/structured model fields as Pydantic-validated JSON options; fail-loud prompt printing - #201

Merged
marlon-costa-dc merged 2 commits into
0.12.0-devfrom
fix/mapping-options-json
Sep 26, 2026
Merged

marlon-costa-dc merged 2 commits into
0.12.0-devfrom
fix/mapping-options-json

Conversation

@marlon-costa-dc

@marlon-costa-dc marlon-costa-dc commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Resolves the known residual from #200: a model field typed as a mapping became a dict Typer option, which Typer rejects at invocation, so the mapping branch of u.Cli.field_default never produced a usable command.

Decision (Pydantic law: owned payloads are Pydantic two-way; JSON enters through Pydantic validation): a field without a native CLI form — a mapping, a nested model, or a collection/union that carries one — is exposed as one string option carrying JSON, validated by Pydantic into the field's declared type.

Design

  • u.Cli.is_json_option(annotation) (new, _options_parts/…part_01) is the single decision owner. It strips aliases and Annotated (u.Cli.unwrap_annotation, shared with the resolver), resolves subscripted type-alias origins (t.MappingKV[...] → Mapping), and flags Mapping subclasses, m.BaseModel subclasses, and generics/unions carrying them.
  • u.Cli.resolve_typer_annotation returns str for those fields (the old dict branch is gone).
  • u.Cli.framework_build_parameter(..., json_annotation=...) sets the Typer parser and the JSON metavar. The parser builds TypeAdapter(<declared type>) only at parse time and runs validate_json; a Pydantic error becomes typer.BadParameter chained from the ValidationError (Click's parser hook otherwise drops a ValueError's text). cli.execute_app returns it as e.fail_validation with the Pydantic cause; cli.invoke_app exits 2 before the handler runs.
  • u.Cli.field_default renders a JSON option's default (settings first, then model default/factory, including nested model instances) as JSON text through u.to_json; the old per-entry mapping normalization and the now-unused t.Cli.MutableDefaultMapping alias are removed.
  • Help appends c.Cli.CLI_JSON_OPTION_HELP to the field description and shows the JSON metavar and the JSON default.

Fail-loud fix

FlextCliPromptsSupport._print_message caught a logger failure and returned a formatted failed Result. The catch is removed so the failure propagates with its cause; the per-caller error templates that only fed that branch are removed from prompts.py.

Tests (behavioural, public surface, no mocks)

New tests/unit/test_model_command_json_options.py registers cli.model_command in a real app:

  • valid JSON mapping reaches the model; nested model and list[model] options reach the model;
  • omitted JSON options deliver model defaults, or settings values when a settings model is given;
  • malformed JSON (json_invalid) and wrong types (int_parsing) fail: invoke_app not succeeded and handler not called; execute_app is a failed Result carrying the Pydantic error type;
  • --help renders the JSON options with the JSON metavar.

test_options_public_cov updated to the new contract (mapping resolves to str; mapping default is JSON text that decodes to the settings value).

Validation (lane worktree /home/marlonsc/flext-work/v8-cli-mapping/flext-cli)

  • make gen ×2: exit 0 both, identical diff (fixed point).
  • make check: exit 0, Total: 1 Success: 1 Failed: 0 (lint, pyrefly, mypy, pyright, silent-failure, security, … OK; duplication/codemod/boundary/namespace/runtime-census are SUSPENDED by the check owner under flext-itpd1.3).
  • make test-full: exit 0, incremental 78 passed, full 1202 passed, 0 failures.

Residuals (not changed here)

  • u.Cli.build_model_command (_utilities/model_commands.py Builder) is a second model-command path that passes raw field annotations to Typer, so it still cannot handle mapping fields; it duplicates cli.model_command.
  • u.Cli.field_default still catches c.EXC_VALIDATION_TYPE_VALUE and maps a non-CLI default to None.
  • Other CLI_SAFE_EXCEPTIONS catches in _prompts_support.py (_run_or_fail) were not flagged and remain.

🤖 Generated with Claude Code


Summary by cubic

Fixes model commands with mapping fields — previously a dict Typer option that Typer rejects at invocation — and makes prompt printing fail loud.

A model field without a native CLI form (a mapping, a nested model, or a collection/union carrying one) now becomes one string option with the JSON metavar. Pydantic validates the text into the field's declared type at parse time.

  • Malformed JSON or a schema mismatch is a usage error, and execute_app returns it with the Pydantic cause.
  • Defaults (settings first, then the model) render as JSON text; help shows the format marker and never builds an adapter.
  • _print_message no longer swallows logger failures — they propagate with their cause, and the per-caller error templates are removed.

Written for commit dbcdb0e. Summary will update on new commits.

Review in cubic

Marlon Costa and others added 2 commits September 26, 2026 09:11
…d JSON options

A model field typed as a mapping resolved to a bare dict Typer option,
which Typer rejects at invocation, so model commands with such fields
could not run. Structured fields without a native CLI form (mappings,
nested models, and collections or unions carrying them) now resolve to
a string option with the JSON metavar. The option parser validates the
text with Pydantic into the field's declared type; malformed JSON or a
schema mismatch is a usage error carrying the Pydantic cause, which
execute_app returns as e.fail_validation. Defaults (settings first, then
the model) render as JSON text; help states the format and never builds
an adapter. The unused MutableDefaultMapping alias is removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
FlextCliPromptsSupport._print_message caught logger failures and turned
them into a failed Result with a formatted message. Per the fail-loud
law the catch is removed so the failure propagates with its cause; the
per-caller error templates that only fed that branch are removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@marlon-costa-dc
marlon-costa-dc merged commit 1d43bb1 into 0.12.0-dev Sep 26, 2026
7 of 9 checks passed
@marlon-costa-dc
marlon-costa-dc deleted the fix/mapping-options-json branch September 26, 2026 12:19
marlon-costa-dc added a commit that referenced this pull request Sep 26, 2026
…t input (#202)

Follow-up to #201. It resolves three rule violations (DRY/SSOT, zero
residue, fail loud).

## 1. One model-command owner
- Deleted `u.Cli.build_model_command` together with its `Builder` and
`SignatureCarrier`. This builder duplicated `cli.model_command` and
passed raw field annotations to Typer, so mapping and nested-model
fields failed. `cli.model_command` stays the single owner and routes
structured fields through `u.Cli.is_json_option`.
- Deleted `u.Cli.derive_model`, which copied `cli.derive_model`.
`u.Cli.model_source_data` stays because it is the primitive
`cli.derive_model` uses.
- Consumers: none outside flext-cli. I grepped every member of the
workspace for `build_model_command`, `u.Cli.derive_model` and
`FlextCliUtilitiesModelCommands` and found only the projected docs note
that says `FlextCliCli.build_model_command(...)` does not exist. That
note is still true.

## 2. `u.Cli.field_default` no longer drops defaults silently
- Removed the `except c.EXC_VALIDATION_TYPE_VALUE: None` catch and the
`case _: None` fall-through. `None` still means "no default".
- Any other default that the CLI cannot represent now fails at
`cli.model_command` build time. The default-source `ValidationError`
escapes unchanged. A default that validates but that no Typer option can
carry raises `TypeError` with the field name
(`c.Cli.ERR_FIELD_DEFAULT_NOT_CLI_VALUE_FMT`). Structured defaults still
go through the JSON-option path.

## 3. Prompt failures propagate
- Removed the `CLI_SAFE_EXCEPTIONS` catch from `confirm` and the
`_guarded`/`_fatal` helpers that `prompt`, `prompt_choice` and
`prompt_password` used. An exception from the input or password reader
now escapes with its cause.
- `KeyboardInterrupt` and `EOFError` are still declared `r.fail`
outcomes of `confirm`, and each result now carries the exception.
- `prompt_choice` never read input. Its `message` parameter was used
only by the removed failure log, so the parameter is gone. No member
calls `prompt_choice`.
- Removed the prompt failure-format constants, which nothing uses now.
`CLI_SAFE_EXCEPTIONS` itself stays because `cmd`, `tables`, `validation`
and `cli_params` still use it.

## Tests
All tests go through the public service. Prompt tests use scripted input
ports and check that reader failures propagate and that cancellation
keeps its cause. Default tests build the command through
`cli.model_command` and check that it fails with the cause. Tests that
only asserted the old normalization were removed.

## Local gates (worktree `fix/cli-single-command-owner-fail-loud`)
- `make gen` twice: exit 0 both times, no diff after the second run.
- `make fmt`: exit 0.
- `make check`: exit 0 (`Total: 1  Success: 1`).
- `make test-full`:
- First run: exit 2.
`test_completion_before_wake_clear_returns_promptly` failed (3.09s,
limit < 2s) and `test_normal_root_exit_leaves_no_descendant` hit its 10s
timeout. Both are timing-sensitive runtime-process tests and ran at load
average ~9.8.
  - Rerun: exit 0 (incremental 2 passed; full 1200 passed).
- These two tests are timing-flaky under load, independent of this
change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
`cli.model_command` is now the single owner for model-backed commands,
unrepresentable field defaults fail at command build time, and prompt
input failures propagate with their cause.

- Removes the duplicate `u.Cli.build_model_command` builder and
`u.Cli.derive_model`; `cli.model_command` and `cli.derive_model` are the
canonical entry points.
- `u.Cli.field_default` no longer swallows invalid defaults; a validated
default no Typer option can carry raises `TypeError` naming the field,
and invalid default sources escape unchanged.
- Prompt readers no longer catch-and-normalize exceptions;
`KeyboardInterrupt` and `EOFError` remain declared outcomes and each
result now carries the exception.
- `prompt_choice` never read input, so its `message` parameter is
removed.

<sup>Written for commit 0999f31.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/flext-sh/flext-cli/pull/202?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant