fix(bundler): reject a top-level non-mapping bundle-catalogs.yml in _merge_config#3659
Conversation
There was a problem hiding this comment.
Pull request overview
Aligns bundle catalog parsing with the sibling reader by rejecting malformed top-level YAML structures.
Changes:
- Adds a top-level mapping validation guard.
- Adds regression tests for list and scalar documents.
- Remaining issue: falsy non-mappings are still coerced to
{}byload_yaml.
Show a summary per file
| File | Description |
|---|---|
src/specify_cli/bundler/models/catalog.py |
Validates the catalog configuration’s top-level shape. |
tests/contract/test_catalog_schema.py |
Tests rejection of truthy non-mapping documents. |
Review details
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Medium
|
Please address Copilot feedback |
…t load_yaml) Address review (Copilot on github#3659): the top-level guard used the shared load_yaml, whose `yaml.safe_load(...) or {}` coerces a FALSY top-level document ([], false, 0, '') to {} BEFORE the isinstance check — so those malformed configs silently fell back to the built-in defaults instead of raising. Only truthy non-mappings ([a,b], 42) were caught. Parse the raw document in both readers of bundle-catalogs.yml (models/catalog._merge_config AND commands_impl/catalog_config._read): an empty document (None) stays a no-op, but every non-mapping top level — falsy or truthy — now raises "expected a mapping at the top level". This keeps the two readers genuinely consistent. Tests cover the falsy cases for both. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Good catch — fixed in 7285066. You're right: Both readers of |
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (1)
src/specify_cli/bundler/models/catalog.py:265
- Keep this read behind
lib/yamlio.load_yamlrather than duplicating its parser and error translation. That module explicitly centralizes bundler reads (src/specify_cli/bundler/lib/yamlio.py:3-6); the actual defect is itssafe_load(...) or {}implementation. Changing the helper to return{}only when the parsed value isNone, then reusing it here, preserves empty-document behavior while exposing falsy non-mappings and prevents duplicated readers from drifting.
try:
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except yaml.YAMLError as exc:
raise BundlerError(f"Invalid YAML in {config_path}: {exc}") from exc
except OSError as exc:
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Medium
|
Please address Copilot feedback |
…t falsy non-mappings) Address review (Copilot re-review of github#3659): the previous fix duplicated YAML parsing + exception wrapping inline in two readers, bypassing the centralized yamlio helper. Instead, correct the root cause in load_yaml. load_yaml did `yaml.safe_load(...) or {}`, which coerced ANY falsy result (None empty-doc, but also [], false, 0, '') to {} — contradicting its own docstring ("{} for an empty document") and hiding malformed non-mapping configs from callers' shape guards. Change to `{} if data is None else data` so only an empty document becomes {}; a non-mapping top level is returned as-parsed. Revert the inline raw-parse in models/catalog._merge_config and commands_impl/catalog_config._read back to the centralized load_yaml; their existing `isinstance(data, dict)` guards now correctly reject falsy non-mappings too. All three load_yaml callers (these two + manifest.from_dict) already guard the top-level shape, so none regresses. Falsy-case tests for both readers retained. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
You're right — reverted the duplicated inline parsing and fixed the root cause in the centralized helper instead. |
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (1)
src/specify_cli/bundler/commands_impl/catalog_config.py:46
- The existing
if data is None: return []immediately below still accepts an explicit top-level YAML null. Onceload_yamlpreserves explicit nulls,_merge_configwill reject this value but_readwill continue silently treating it as empty, leaving the two readers inconsistent. Truly empty documents already become{}, so remove theNonespecial case.
# ``load_yaml`` returns ``{}`` only for an empty document and the raw parse
# otherwise, so a falsy non-mapping (``[]``/``false``/``0``/``''``) is caught
# by the isinstance guard below and raised like a truthy one, staying
# consistent with the other reader of this file (models/catalog._merge_config).
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Medium
|
Please address Copilot feedback |
…merge_config
_merge_config silently ignored a top-level non-mapping document (a YAML list
or scalar) — `data.get("catalogs") if isinstance(data, dict) else None` made
it fall through to the built-in default stack — while the sibling reader of
the SAME file (commands_impl/catalog_config._read) raises "expected a mapping
at the top level". github#3623 already made the inner non-list `catalogs` value
agree between the two readers; this closes the remaining top-level-shape gap
so both readers reject the same malformed documents.
An empty file (load_yaml coerces to {}), absent `catalogs`, and `catalogs: []`
all remain no-ops.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t load_yaml) Address review (Copilot on github#3659): the top-level guard used the shared load_yaml, whose `yaml.safe_load(...) or {}` coerces a FALSY top-level document ([], false, 0, '') to {} BEFORE the isinstance check — so those malformed configs silently fell back to the built-in defaults instead of raising. Only truthy non-mappings ([a,b], 42) were caught. Parse the raw document in both readers of bundle-catalogs.yml (models/catalog._merge_config AND commands_impl/catalog_config._read): an empty document (None) stays a no-op, but every non-mapping top level — falsy or truthy — now raises "expected a mapping at the top level". This keeps the two readers genuinely consistent. Tests cover the falsy cases for both. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t falsy non-mappings) Address review (Copilot re-review of github#3659): the previous fix duplicated YAML parsing + exception wrapping inline in two readers, bypassing the centralized yamlio helper. Instead, correct the root cause in load_yaml. load_yaml did `yaml.safe_load(...) or {}`, which coerced ANY falsy result (None empty-doc, but also [], false, 0, '') to {} — contradicting its own docstring ("{} for an empty document") and hiding malformed non-mapping configs from callers' shape guards. Change to `{} if data is None else data` so only an empty document becomes {}; a non-mapping top level is returned as-parsed. Revert the inline raw-parse in models/catalog._merge_config and commands_impl/catalog_config._read back to the centralized load_yaml; their existing `isinstance(data, dict)` guards now correctly reject falsy non-mappings too. All three load_yaml callers (these two + manifest.from_dict) already guard the top-level shape, so none regresses. Falsy-case tests for both readers retained. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…l in load_yaml Address review (Copilot on github#3659): yaml.safe_load returns None for BOTH an empty document AND an explicit null scalar (`null`/`~`), so mapping None to {} still let a top-level null bundle-catalogs.yml fall back to defaults instead of being rejected by the mapping guard. Use yaml.compose (which yields a node only for a non-empty document) to tell the two apart: a truly empty document becomes {}, while an explicit null is returned as None so the callers' isinstance(dict) guard rejects it like any other non-mapping. Drop the now-incorrect `if data is None: return []` short-circuit in catalog_config._read so an explicit null reaches that guard. Tests cover null/~ for both readers plus empty/comment-only no-op. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
09a5fd0 to
4a77776
Compare
|
Good point — fixed. |
|
Thank you! |
What
_merge_configsilently ignored a top-level non-mappingbundle-catalogs.yml(a YAML list or scalar):data.get("catalogs") if isinstance(data, dict) else Nonemade a list/scalar document fall through toNone→ early return → the built-in default stack, with no error.The sibling reader of the same file —
commands_impl/catalog_config._read— raises"expected a mapping at the top level"for exactly those documents. Merged PR #3623 already made the inner non-listcatalogsvalue agree between the two readers; this closes the remaining top-level-shape gap so both readers reject the same malformed input.Fix
Add an explicit top-level mapping guard mirroring
_read:An empty file (
load_yamlcoerces to{}), absentcatalogs, andcatalogs: []all remain no-ops.Tests
tests/contract/test_catalog_schema.py::test_toplevel_non_mapping_raises(parametrized over a top-level list and a scalar) — both raise after the fix, silently return the default stack before it. Regression tests confirm absent/emptycatalogsstill yield the built-in defaults.ruffclean; all 23 contract tests pass.AI-assisted: authored with Claude Code. Verified the message/behavior matches the
_readsibling and confirmed fail-before/pass-after.