fix(cli): mapping/structured model fields as Pydantic-validated JSON options; fail-loud prompt printing - #201
Merged
Conversation
…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>
|
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. -->
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Summary
Resolves the known residual from #200: a model field typed as a mapping became a
dictTyper option, which Typer rejects at invocation, so the mapping branch ofu.Cli.field_defaultnever 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 andAnnotated(u.Cli.unwrap_annotation, shared with the resolver), resolves subscripted type-alias origins (t.MappingKV[...]→Mapping), and flagsMappingsubclasses,m.BaseModelsubclasses, and generics/unions carrying them.u.Cli.resolve_typer_annotationreturnsstrfor those fields (the olddictbranch is gone).u.Cli.framework_build_parameter(..., json_annotation=...)sets the Typerparserand theJSONmetavar. The parser buildsTypeAdapter(<declared type>)only at parse time and runsvalidate_json; a Pydantic error becomestyper.BadParameterchainedfromtheValidationError(Click's parser hook otherwise drops aValueError's text).cli.execute_appreturns it ase.fail_validationwith the Pydantic cause;cli.invoke_appexits 2 before the handler runs.u.Cli.field_defaultrenders a JSON option's default (settings first, then model default/factory, including nested model instances) as JSON text throughu.to_json; the old per-entry mapping normalization and the now-unusedt.Cli.MutableDefaultMappingalias are removed.c.Cli.CLI_JSON_OPTION_HELPto the field description and shows theJSONmetavar and the JSON default.Fail-loud fix
FlextCliPromptsSupport._print_messagecaught 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 fromprompts.py.Tests (behavioural, public surface, no mocks)
New
tests/unit/test_model_command_json_options.pyregisterscli.model_commandin a real app:list[model]options reach the model;json_invalid) and wrong types (int_parsing) fail:invoke_appnot succeeded and handler not called;execute_appis a failed Result carrying the Pydantic error type;--helprenders the JSON options with theJSONmetavar.test_options_public_covupdated to the new contract (mapping resolves tostr; 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, incremental78 passed, full1202 passed, 0 failures.Residuals (not changed here)
u.Cli.build_model_command(_utilities/model_commands.pyBuilder) is a second model-command path that passes raw field annotations to Typer, so it still cannot handle mapping fields; it duplicatescli.model_command.u.Cli.field_defaultstill catchesc.EXC_VALIDATION_TYPE_VALUEand maps a non-CLI default toNone.CLI_SAFE_EXCEPTIONScatches 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
dictTyper 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
JSONmetavar. Pydantic validates the text into the field's declared type at parse time.execute_appreturns it with the Pydantic cause._print_messageno 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.