fix(storage): reject malformed describe-batch --from-file shapes with a structured error (#640) - #645
Conversation
… a structured error (#640) A `--from-file` document whose `tables:` (or `buckets:` / `columns:`) section was a list instead of a mapping reached the write loop and died on `.items()` with an AttributeError -- a Rich traceback on stdout even when `--json` was passed, so a programmatic consumer (serve, a scheduled agent task, CI) got an unparseable blob instead of an error envelope. The traceback also named `tables.items()` deep in the service rather than the key the author had to fix. The whole document is now validated before the first write, in a new `services/_describe_batch_input.py` (storage_service.py is over its file-size budget). A wrong shape raises ValueError -- the error the command already maps to INVALID_ARGUMENT and exit 2 -- with a message naming the offending key, its actual type in YAML vocabulary, and a copy-pasteable example of the right shape. Covered: a non-mapping section, a non-mapping `columns` entry (previously a soft per-item error, so a bad file could half-apply), a container where a description string belongs, a top-level document that is not a mapping, and a YAML syntax error. API failures stay partial-failure-tolerant as before. Closes #640
padak
left a comment
There was a problem hiding this comment.
Review of #645 — fix(storage): reject malformed describe-batch --from-file shapes with a structured error (#640)
Generated by
kbagent-pr-reviewersubagent. Verdict and findings below
are advisory; the human author retains every veto. CI-coverable issues
(lint, format, tests) are confirmed viamake check, not duplicated here.
Summary
This PR moves storage describe-batch --from-file shape validation up front (new services/_describe_batch_input.py, a frozen DescribeBatchInput dataclass) so a malformed YAML document (tables:/buckets:/columns: as a list, a non-mapping columns entry, a description that is itself a container, or a non-mapping top-level document) is rejected whole with INVALID_ARGUMENT / exit 2 instead of crashing on .items() with a Rich traceback under --json. It deliberately changes a previously-tolerant path (a bad columns: entry used to be a soft per-item error that let the rest of the file half-apply) into a fail-fast usage error, and updates gotchas.md, storage-describe-workflow.md, and commands-reference.md to state that the existing partial-failure tolerance now covers API failures only. Verdict: COMMENT. The implementation, tests, and doc updates are solid and internally consistent; the only gap found is the --help docstring on the Typer command itself, which still describes the old blanket tolerance.
Verdict
- Verdict: COMMENT
- Blocking findings: 0
- Non-blocking findings: 1
- Nits: 0
Blocking findings
(none)
Non-blocking findings
[NB-1] src/keboola_agent_cli/commands/_storage_describe.py:302 — --help docstring still claims blanket per-item tolerance
The Typer command docstring for storage describe-batch still reads "All sections are optional. A failure in one item does not abort the rest -- all results are collected and reported." Verified live (kbagent storage describe-batch --help) -- this text is unqualified and now contradicts the new fail-fast-on-malformed-shape behavior that gotchas.md, storage-describe-workflow.md, commands-reference.md, and the storage_service.py docstring were all correctly updated to describe in this same PR. --help is the "ultimate fallback when documentation drifts" per CONTRIBUTING.md's CLI-surface checklist, and it is now the one surface out of sync. Suggest appending a sentence like the one added to storage_service.py: "This tolerance covers per-item API failures only -- a malformed file section is rejected whole before any write."
Nits
(none)
Verification log
gh pr view 645 --json title,body,files,additions,deletions,baseRefName,headRefName,labels,state-> OPEN, basemain, headfix/issue-640-describe-batch-validation, 7 files, +402/-34, conventionalfix(storage):matches a bug-fix change ✓- Diff scope cross-checked against PR description (a) malformed-shape validation, (b)
_describe_batch_input.pyextraction, (c) 3 doc-surface corrections, (d) tests -- diff contains exactly these and nothing extraneous ✓ git worktree add --detachatFETCH_HEADoforigin/fix/issue-640-describe-batch-validation(491734d) in an isolated scratch dir -- invoking session's own worktree HEAD never moved ✓- Layer-violation greps (typer/click/formatter in
services/, httpx/requests incommands/, formatter/typer in HTTP clients) -> all empty, no violations ✓ - Convention greps (magic numbers, raw
error_code="..."strings, bareexcept:,print()in prod code, token leakage, new heterogeneoustuple[...]returns) -> all empty; the two-> tuple[...]annotations that did appear are in test-helper functions (_service/_runin the newTestDescribeBatchShapeValidationclass), matching an existing convention used in 33 other test files -- not flagged ✓ make version-sync/uv sync --extra serverin the isolated worktree, thenmake check-> exit 0: ruff check clean, ruff format clean (450 files),ty check1 pre-existing warning (hatchlingunresolved-import inscripts/hatch_build.py, unrelated to this diff, warning-only per CONTRIBUTING), SKILL.md up to date, version in sync, 419/419 version gates resolve,check_command_sync.py: all 262 CLI commands registered + documented (confirms no new/renamed command surface introduced by this PR --OPERATION_REGISTRY,CLAUDE.md,context.py,commands-reference.mdsync is unaffected), changelog complete (no release bump in this PR, consistent with PR description), error-codes clean, sentinel-guards clean, file-size gate OK (only pre-existing soft-ceiling warnings on unrelated files), 5874 passed, 12 skipped ✓- Confirmed
"storage.describe-batch": "write"already present inpermissions.pyOPERATION_REGISTRY(pre-existing, command signature unchanged, no new entry needed) ✓ - Confirmed
storage describe-batchalready listed inCLAUDE.mdandcommands/context.pyAGENT_CONTEXT(unchanged, signature-compatible) ✓ - Live behavior reproduction against the isolated worktree build with the exact repro YAML from the issue (
tables:as a list of objects):--jsonmode:{"status": "error", "error": {"code": "INVALID_ARGUMENT", "message": "'tables' must be a mapping of table ID to description, got a list. Expected:\ntables:\n in.c-sales.orders: All sales orders", ...}}, exit 2 ✓ (matches PR description verbatim)- human mode: same message rendered as
Error: ..., exit 2 ✓ (both output surfaces verified independently)
- Confirmed via
kbagent storage describe-batch --helpthat the Typer docstring was NOT updated -> basis for[NB-1]✓ - Checked
gotchas.mdfor the(Release step: ... tag this sentence (since vX.Y.Z) ...)placeholder pattern used instead of an actual version tag -- found an identical pre-existing placeholder already onmainat line 2693 for a different (already-merged, not-yet-released) gotcha, confirming this is an established repo convention for doc changes landing between releases, not a deviation introduced by this PR -- not flagged as a finding ✓ - Cross-checked all three updated doc surfaces (
gotchas.md,storage-describe-workflow.md,commands-reference.md) for consistency: all three state the same contract (API failures partial-tolerant / shape failures fail-fast withINVALID_ARGUMENT+ exit 2, nothing half-applied) with no contradictions between them ✓ git diff main...HEAD --stat-- 11 new service-layer tests (TestDescribeBatchShapeValidation), 1 new CLI-layer test (test_describe_batch_malformed_shape_json_envelope, runs the realStorageServicewith a client factory that raisesAssertionErrorif reached, proving the API is never touched on a malformed file); pre-existing E2E coverage fordescribe-batchalready exists intests/test_e2e.py:3979from the command's original introduction -- no new E2E test needed for a pure input-validation fix with no new API surface ✓
Open questions for the author
(none)
) Three correctness holes in the shape check, plus the doc surfaces that still described the old semantics. A null description (a bare `in.c-sales:` key) passed the container check and wrote the literal text "None" onto the object; it is now an error naming the key. Two YAML keys of different types that coerce to the same string (`1:` and `"1":`) silently overwrote each other, dropping a description the author wrote and never saw applied; the collision is now detected during coercion. The bigger one is a back-compat regression the first pass introduced: the old `raw.get(key) or {}` made an EMPTY falsy section (`buckets: []`, `buckets: ""`) a silent no-op, and validating the shape turned that into exit 2 -- breaking a generated file whose sections legitimately came out empty. None, `[]`, `""` and `{}` are an empty section again, at both levels; only a NON-EMPTY wrong shape and other scalars (`false`, `0`, a non-empty string) stay errors, since those carry content that would otherwise be dropped silently. The describe-batch --help text and the AGENT_CONTEXT entry both still claimed that no failure aborts the batch. Both now separate the two regimes: a malformed file aborts before any write (exit 2), per-item API failures during application are still collected (exit 1). _SECTION_SUBJECTS and _SECTION_EXAMPLES merged into one `_Section` NamedTuple per section so a reworded rule cannot drift from its example, with the top-level example built from that single source.
sync clone's --bucket-map / --variable-values / --instance-rename loader
coerced every YAML value with bare str(value), so a nested mapping (one
fat-fingered colon away from valid input, e.g. 'in.c-old:' followed by an
indented 'new: in.c-new') was silently used as the literal string
"{'new': 'in.c-new'}" -- a bogus bucket ID pushed into the target project.
Extract the load-and-validate logic into a shared yaml_input module
(load_flat_scalar_mapping + yaml_type_name) that rejects non-scalar values
and None with a ConfigError naming the offending key and its actual type in
YAML vocabulary (mapping/list/null), mirroring the describe-batch shape
validation approach from PR #645. Scalars keep the existing str coercion.
commands/flow.py's _load_flow_yaml is deliberately left alone: flow
definitions are legitimately nested, so only the top-level-mapping check is
shared there and it already rejects correctly.
…dation Now that #645 is on main, drop _describe_batch_input.py's private _TYPE_NAMES/_type_name copy in favor of the shared yaml_input.yaml_type_name introduced for the sync clone override validation -- the reuse follow-up both PRs promised. Behavior unchanged; the describe-batch error messages keep their per-section subjects and examples.
…has.md Both '(Release step: once this ships, tag this sentence (since vX.Y.Z)...)' parentheticals in the storage-descriptions gotcha referred to behavior that has since merged: the table-detail human-mode Description column (#642) and the describe-batch --from-file shape rejection (#645). Neither commit is contained in the v0.88.0 tag and main is bumped to 0.89.0, so both are tagged (since v0.89.0) -- a version version-gate-check resolves via its existing changelog entry. This is a live instance of the leftover-placeholder failure mode this PR's release checklist step 4 now guards against.
pyproject/plugin.json/marketplace.json were already renumbered to 0.89.0 by v0.88.0. Changelog: adds 0.89.0 entries for #645 (describe-batch --from-file shape validation, issue #640), #642 (table-detail human column descriptions), #620 (sync-action forwards root authorization/runtime), #517 (stable metavar contract, issue #513), #586 (documented prompt budget gated against the enforced one, issue #585) and #641 (docs-only), and decorates the existing Silent-drift surfaces: * gotchas.md -- resolves both "(Release step: ... tag this sentence)" placeholders. Both were left by commits AFTER the v0.88.0 tag (#642 and #645), so both are tagged (since v0.89.0), not 0.88.0. Adds the #620 gotcha: below 0.89.0 a sync action on an OAuth / Service-Account component died with an opaque empty-body 400 because the broker reference was never forwarded. * #620 shipped with no doc surfaces at all -- CLAUDE.md, AGENT_CONTEXT and commands-reference.md now carry the forwarding rule (root only, never row-overridden, only when non-empty) with its version gate. * #645 never reached CLAUDE.md -- the describe-batch shape check and its behaviour change are recorded there now; commands-reference gains the version tag. * #642's human Description column is version-tagged in CLAUDE.md, commands-reference.md, AGENT_CONTEXT and storage-describe-workflow.md. * #643 was otherwise complete; adds the two surfaces it did not touch -- safe-write-workflow.md (delete is reversible; never blind-retry on <= 0.88.x) and a keboola-expert.md matrix row for delete/restore/trash-list. keboola-expert.md is 49 774 B, well inside the 70 000 B budget. make check green: 5934 passed, 12 skipped. version-gate-check resolves all 438 markers across 72 versions.
#648) * docs: stop bumping the version in feature PRs; releases become dedicated release PRs Parallel feature PRs (one AI session per issue) each bumping pyproject.toml collide on every merge and silently renumber releases -- the KNOWN_UNRELEASED list in scripts/generate_changelog.py is the accumulated damage of that pattern. Version bumps and changelog.py entries now happen ONLY in a dedicated release PR that batches everything merged since the last release. - CLAUDE.md Versioning: new 'Version bumps happen ONLY in a dedicated release PR' subsection with the two-step flow and the vNEXT placeholder convention for version-gated docs in feature PRs (version-gate-check rejects guessed numeric versions in per-PR CI, so the placeholder is the only workable tag before the release assigns a number). - CONTRIBUTING.md: new 'No version bumps in feature PRs' commit convention; per-command gotchas checklist and sync map updated for vNEXT; 'Releasing a new version' rewritten around the release PR -- new step 1 (collect merged PRs since the last tag as the exact scope of the changelog and release notes) and step 4 (replace every vNEXT, grep must come back empty); beta releases documented as the one deliberate exception where the bump rides the feature branch. * docs(plugin): resolve the two stale release-step placeholders in gotchas.md Both '(Release step: once this ships, tag this sentence (since vX.Y.Z)...)' parentheticals in the storage-descriptions gotcha referred to behavior that has since merged: the table-detail human-mode Description column (#642) and the describe-batch --from-file shape rejection (#645). Neither commit is contained in the v0.88.0 tag and main is bumped to 0.89.0, so both are tagged (since v0.89.0) -- a version version-gate-check resolves via its existing changelog entry. This is a live instance of the leftover-placeholder failure mode this PR's release checklist step 4 now guards against.
pyproject/plugin.json/marketplace.json were already renumbered to 0.89.0 by v0.88.0. Changelog: adds 0.89.0 entries for #645 (describe-batch --from-file shape validation, issue #640), #642 (table-detail human column descriptions), #620 (sync-action forwards root authorization/runtime), #517 (stable metavar contract, issue #513), #586 (documented prompt budget gated against the enforced one, issue #585) and #641 (docs-only), and decorates the existing Silent-drift surfaces: * gotchas.md -- resolves both "(Release step: ... tag this sentence)" placeholders. Both were left by commits AFTER the v0.88.0 tag (#642 and #645), so both are tagged (since v0.89.0), not 0.88.0. Adds the #620 gotcha: below 0.89.0 a sync action on an OAuth / Service-Account component died with an opaque empty-body 400 because the broker reference was never forwarded. * #620 shipped with no doc surfaces at all -- CLAUDE.md, AGENT_CONTEXT and commands-reference.md now carry the forwarding rule (root only, never row-overridden, only when non-empty) with its version gate. * #645 never reached CLAUDE.md -- the describe-batch shape check and its behaviour change are recorded there now; commands-reference gains the version tag. * #642's human Description column is version-tagged in CLAUDE.md, commands-reference.md, AGENT_CONTEXT and storage-describe-workflow.md. * #643 was otherwise complete; adds the two surfaces it did not touch -- safe-write-workflow.md (delete is reversible; never blind-retry on <= 0.88.x) and a keboola-expert.md matrix row for delete/restore/trash-list. keboola-expert.md is 49 774 B, well inside the 70 000 B budget. make check green: 5934 passed, 12 skipped. version-gate-check resolves all 438 markers across 72 versions.
…es (#651) Release prep for 0.89.0: adds the changelog entries for everything merged since v0.88.0 (#620, #642, #643, #644, #645, #646, #647, #648, #649, #650, #517, #586, #641), resolves every vNEXT placeholder left by feature PRs to v0.89.0 per the new #648 release process, closes the 10 gaps a full doc-surface audit found across the kbagent plugin (SKILL.md triggers, commands-reference, gotchas, workflow files, keboola-expert.md, AGENT_CONTEXT, CLAUDE.md), and records the live e2e verification evidence. Version files were already at 0.89.0 (bumped by #643); make version-sync is a no-op.
What
storage describe-batch --from-filenow validates the parsed YAML document'sshape before the first write and rejects a malformed one with a structured
error (
INVALID_ARGUMENT, exit 2) instead of crashing.Why
A section that was a list instead of a mapping reached the write loop and died
on
.items():Two problems, both about the failure mode rather than the rejection:
--jsonwas answered with a Rich traceback. A programmatic consumer(the
serveREST layer, a scheduled agent task, a CI script) got anunparseable blob where every other command in this repo emits an envelope.
tables.items()deep inside the service, not the keyof the file that was wrong -- the one thing the author needed.
Same hole existed for
buckets:,columns:, a document that is not a mappingat all, and a container where a description string belongs (which used to be
str()-ed into garbage and written to the API).New behavior
Same input now:
services/_describe_batch_input.py(
storage_service.pyis over its file-size budget, so the pureinput-handling is what moves out -- same precedent as
_storage_tables.py).It returns a frozen
DescribeBatchInputdataclass with descriptions alreadycoerced to
str, so the write loop stays type-free.ValueError, which is exactly what the command already maps toErrorCode.INVALID_ARGUMENT+ exit 2 (the missing-file path) -- no newwiring, no new error code.
columns.in.c-sales.orders.order_id), theactual type in YAML vocabulary (
got a list/a string/a mapping), anda copy-pasteable example of the right shape.
columnsentry used to be a soft per-item error, so a bad filecould half-apply. It is now a fail-fast usage error like the rest: nothing is
written, so fixing the file and re-running is always safe.
errors[],exit 1). Only shape errors abort.
No version bump and no
changelog.pychange (0.89.0 is assembled separately).Doc surfaces that stated the tolerance without qualifying it --
gotchas.md,storage-describe-workflow.md,commands-reference.md-- were corrected inplace; no new doc surface was added.
Tests
tests/test_storage_describe_service.py-- newTestDescribeBatchShapeValidation(each asserts the message and that no API write was attempted):
test_tables_as_list-- the exact repro from the issuetest_buckets_as_listtest_columns_as_listtest_columns_entry_scalar/test_columns_entry_listtest_column_description_is_a_containertest_table_description_is_a_containertest_top_level_scalartest_malformed_yaml_syntaxtest_nothing_is_applied_when_a_later_section_is_malformed-- a badcolumns:must not let an earlier validbuckets:throughtest_valid_file_still_applies-- happy-path regression, including thenon-string scalar (
2026) still coercing to"2026"tests/test_storage_describe_cli.py--test_describe_batch_malformed_shape_json_envelope:end-to-end through the CLI with the real
StorageServiceand a clientfactory that raises if reached, asserting exit 2,
status: error,code: INVALID_ARGUMENT, and the key/type in the message.make checkpasses: lint, format, typecheck, skill/version/command-sync/changelog/error-code/sentinel/file-size gates, and 5874 tests.
Closes #640