Skip to content

Commit 52a6514

Browse files
jawwad-aliclaude
andauthored
fix(workflows): reject falsy non-mapping workflow-catalogs.yml top level (#3707)
* fix(workflows): reject falsy non-mapping workflow-catalogs.yml top level WorkflowCatalog._load_catalog_config parsed the config with `yaml.safe_load(...) or {}`, then checked `isinstance(data, dict)`. The `or {}` coerces a FALSY non-mapping top level (`[]`, `false`, `0`, `''`) to `{}` *before* the guard runs, so those are silently swallowed as "empty config" and fall back to the built-in defaults -- while a TRUTHY non-mapping (`5`, a bare list) correctly raises. Same silent-swallow inconsistency the bundler catalog reader fixed for its own config. Drop the `or {}` and branch on `None` (empty document / explicit `null`) explicitly: `None` stays a valid no-op, every non-mapping (falsy or truthy) now raises the same actionable error. Correct configs are unaffected. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(workflows): describe the catalog-config fallthrough accurately The comment said a None return means "no project catalogs, fall back to the built-in defaults". Both halves were imprecise: _load_catalog_config serves the project AND user configs, and get_active_catalogs falls through env -> project -> user -> built-in, so a None from the project layer moves on to the USER config; the built-in defaults apply only once every layer returned None. Reword the loader comment and the mirror test docstring. Comments only -- no behaviour change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(workflows): close the same falsy-mask gap in 'catalogs' and StepCatalog Self-review follow-up: the top-level fix left the identical asymmetry live five lines below, and again in this file's twin loader. 1. WorkflowCatalog._load_catalog_config: the ``catalogs`` shape check sat behind an emptiness check, so a FALSY non-list (``catalogs: {}``/``''``/``0``/ ``false``) was silently swallowed as "no catalogs" while ``catalogs: 5`` raised. Verified before this commit: ``catalogs: {}`` -> None (no error). Shape now checked first; absent/explicit-null and empty-list stay no-ops (matching the bundler's reader). 2. StepCatalog._load_catalog_config -- the step-catalog twin, read the same way -- still had ``yaml.safe_load(...) or {}``, so falsy non-mappings bypassed its isinstance guard (``[]`` -> None while ``5`` raised). Same two guards applied, keeping the two loaders in lockstep. Eight new parametrized cases, all failing before this commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(workflows): move StepCatalog guards into TestStepCatalog and add the nested case Address three review points: 1. The StepCatalog regression tests sat inside TestWorkflowCatalog, so a targeted `pytest ...::TestStepCatalog` run skipped them entirely. Moved into that class, where the duplicated twin loader belongs. 2. StepCatalog had no nested-value coverage (only top-level). Added the parametrized falsy ``catalogs:`` case, plus the absent/null/empty no-op cases. Verified against upstream/main's catalog.py: 8 fail there, pass here. 3. Dropped the inaccurate parity parenthetical. src/specify_cli/catalogs.py RAISES for missing/empty ``catalogs`` and coerces a null document to {}, so it is not the behavior this loader matches -- the comment now just states what changed (only the misreported shapes) without claiming parity. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3dad624 commit 52a6514

2 files changed

Lines changed: 133 additions & 8 deletions

File tree

src/specify_cli/workflows/catalog.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -335,26 +335,45 @@ def _load_catalog_config(
335335
if not config_path.exists():
336336
return None
337337
try:
338-
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
338+
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
339339
except (yaml.YAMLError, OSError, UnicodeError) as exc:
340340
raise WorkflowValidationError(
341341
f"Failed to read catalog config {config_path}: {exc}"
342342
) from exc
343+
# An empty document (or explicit ``null``) parses to None -> this config
344+
# layer contributes nothing, so ``get_active_catalogs`` moves on to the
345+
# next layer (this loader serves both the project and user configs;
346+
# the built-in defaults apply only once every layer has returned None).
347+
# Do NOT coerce with ``or {}`` here: that also turns a FALSY non-mapping
348+
# (top-level ``[]``, ``false``, ``0``, ``''``) into ``{}`` and silently
349+
# swallows it, while a TRUTHY non-mapping (``5``, a bare list) correctly
350+
# raises below -- an inconsistency. Only None means "no document".
351+
if data is None:
352+
return None
343353
if not isinstance(data, dict):
344354
raise WorkflowValidationError(
345355
f"Invalid catalog config: expected a mapping, "
346356
f"got {type(data).__name__}"
347357
)
348-
catalogs_data = data.get("catalogs", [])
349-
if not catalogs_data:
350-
# Empty catalogs list (e.g. after removing last entry)
351-
# is valid — fall back to built-in defaults.
358+
# Same asymmetry as the top level above, one nesting level down: the
359+
# shape check has to run BEFORE the emptiness check, or a FALSY non-list
360+
# (``catalogs: {}``/``''``/``0``/``false``) is silently swallowed as
361+
# "no catalogs" while a TRUTHY non-list (``catalogs: 5``) correctly
362+
# raises. An absent key, an explicit ``catalogs:`` null, and an empty
363+
# list all keep their existing "nothing configured here" behavior --
364+
# only the misreported shapes change.
365+
catalogs_data = data.get("catalogs")
366+
if catalogs_data is None:
352367
return None
353368
if not isinstance(catalogs_data, list):
354369
raise WorkflowValidationError(
355370
f"Invalid catalog config: 'catalogs' must be a list, "
356371
f"got {type(catalogs_data).__name__}"
357372
)
373+
if not catalogs_data:
374+
# Empty catalogs list (e.g. after removing last entry)
375+
# is valid — fall back to built-in defaults.
376+
return None
358377

359378
entries: list[WorkflowCatalogEntry] = []
360379
for idx, item in enumerate(catalogs_data):
@@ -1018,24 +1037,33 @@ def _load_catalog_config(
10181037
if not config_path.exists():
10191038
return None
10201039
try:
1021-
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
1040+
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
10221041
except (yaml.YAMLError, OSError, UnicodeError) as exc:
10231042
raise StepValidationError(
10241043
f"Failed to read catalog config {config_path}: {exc}"
10251044
) from exc
1045+
# Same two guards as WorkflowCatalog._load_catalog_config above, kept in
1046+
# lockstep: this is the step-catalog twin of that loader and read the
1047+
# same way. Dropping ``or {}`` stops a falsy non-mapping top level from
1048+
# being coerced past the isinstance check, and the ``catalogs`` shape
1049+
# check runs before the emptiness check for the same reason.
1050+
if data is None:
1051+
return None
10261052
if not isinstance(data, dict):
10271053
raise StepValidationError(
10281054
f"Invalid catalog config: expected a mapping, "
10291055
f"got {type(data).__name__}"
10301056
)
1031-
catalogs_data = data.get("catalogs", [])
1032-
if not catalogs_data:
1057+
catalogs_data = data.get("catalogs")
1058+
if catalogs_data is None:
10331059
return None
10341060
if not isinstance(catalogs_data, list):
10351061
raise StepValidationError(
10361062
f"Invalid catalog config: 'catalogs' must be a list, "
10371063
f"got {type(catalogs_data).__name__}"
10381064
)
1065+
if not catalogs_data:
1066+
return None
10391067

10401068
entries: list[StepCatalogEntry] = []
10411069
for idx, item in enumerate(catalogs_data):

tests/test_workflows.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6422,6 +6422,57 @@ def test_project_level_config(self, project_dir):
64226422
assert len(entries) == 1
64236423
assert entries[0].name == "custom"
64246424

6425+
@pytest.mark.parametrize("body", ["[]\n", "false\n", "0\n", "''\n"])
6426+
def test_falsy_non_mapping_config_rejected(self, project_dir, body):
6427+
"""A FALSY non-mapping top-level config ([], false, 0, '') must raise,
6428+
like a truthy non-mapping (5, a bare list) already does. The previous
6429+
``yaml.safe_load(...) or {}`` coerced these to {} and silently swallowed
6430+
them, diverging from the truthy case."""
6431+
from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError
6432+
6433+
config_path = project_dir / ".specify" / "workflow-catalogs.yml"
6434+
config_path.write_text(body, encoding="utf-8")
6435+
catalog = WorkflowCatalog(project_dir)
6436+
with pytest.raises(WorkflowValidationError, match="expected a mapping"):
6437+
catalog._load_catalog_config(config_path)
6438+
6439+
@pytest.mark.parametrize("body", ["catalogs: {}\n", "catalogs: ''\n", "catalogs: 0\n", "catalogs: false\n"])
6440+
def test_falsy_non_list_catalogs_rejected(self, project_dir, body):
6441+
"""A FALSY non-list ``catalogs:`` value must raise, like a truthy one
6442+
(``catalogs: 5``) already does. The shape check sat behind the emptiness
6443+
check, so these were silently swallowed as "no catalogs"."""
6444+
from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError
6445+
6446+
config_path = project_dir / ".specify" / "workflow-catalogs.yml"
6447+
config_path.write_text(body, encoding="utf-8")
6448+
catalog = WorkflowCatalog(project_dir)
6449+
with pytest.raises(WorkflowValidationError, match="'catalogs' must be a list"):
6450+
catalog._load_catalog_config(config_path)
6451+
6452+
@pytest.mark.parametrize("body", ["catalogs:\n", "catalogs: []\n"])
6453+
def test_absent_or_empty_catalogs_is_noop(self, project_dir, body):
6454+
"""An explicit ``catalogs:`` null or an empty list stays a valid no-op —
6455+
the layer contributes nothing and resolution falls through."""
6456+
from specify_cli.workflows.catalog import WorkflowCatalog
6457+
6458+
config_path = project_dir / ".specify" / "workflow-catalogs.yml"
6459+
config_path.write_text(body, encoding="utf-8")
6460+
catalog = WorkflowCatalog(project_dir)
6461+
assert catalog._load_catalog_config(config_path) is None
6462+
6463+
@pytest.mark.parametrize("body", ["", "# only a comment\n", "null\n", "~\n"])
6464+
def test_empty_or_null_config_is_noop(self, project_dir, body):
6465+
"""An empty document, comment-only file, or explicit top-level null is a
6466+
valid no-op: the loader returns None so that config layer is skipped and
6467+
get_active_catalogs falls through to the next one. It must NOT be
6468+
confused with a falsy non-mapping, which raises."""
6469+
from specify_cli.workflows.catalog import WorkflowCatalog
6470+
6471+
config_path = project_dir / ".specify" / "workflow-catalogs.yml"
6472+
config_path.write_text(body, encoding="utf-8")
6473+
catalog = WorkflowCatalog(project_dir)
6474+
assert catalog._load_catalog_config(config_path) is None
6475+
64256476
@pytest.mark.parametrize("bad_priority", [True, False, float("inf")])
64266477
def test_config_priority_bool_or_inf_rejected(self, project_dir, bad_priority):
64276478
"""`priority: true` must not be silently coerced to 1, and `priority: .inf`
@@ -7045,6 +7096,52 @@ def test_registry_save_refuses_symlinked_steps_dir(self, project_dir):
70457096
class TestStepCatalog:
70467097
"""Test StepCatalog catalog resolution."""
70477098

7099+
# -- Config shape guards ----------------------------------------------
7100+
# StepCatalog._load_catalog_config is a duplicated twin of
7101+
# WorkflowCatalog._load_catalog_config, so it needs its own coverage: a
7102+
# regression in one loader would not be caught by the other's tests.
7103+
7104+
@pytest.mark.parametrize("body", ["[]\n", "false\n", "0\n", "''\n"])
7105+
def test_falsy_non_mapping_config_rejected(self, project_dir, body):
7106+
"""A FALSY non-mapping top level had the same ``or {}`` coercion, which
7107+
bypassed the isinstance guard. It must raise like a truthy non-mapping."""
7108+
from specify_cli.workflows.catalog import StepCatalog, StepValidationError
7109+
7110+
config_path = project_dir / ".specify" / "step-catalogs.yml"
7111+
config_path.write_text(body, encoding="utf-8")
7112+
catalog = StepCatalog(project_dir)
7113+
with pytest.raises(StepValidationError, match="expected a mapping"):
7114+
catalog._load_catalog_config(config_path)
7115+
7116+
@pytest.mark.parametrize(
7117+
"body", ["catalogs: {}\n", "catalogs: ''\n", "catalogs: 0\n", "catalogs: false\n"]
7118+
)
7119+
def test_falsy_non_list_catalogs_rejected(self, project_dir, body):
7120+
"""...and the same nested guard: a FALSY non-list ``catalogs:`` value must
7121+
raise rather than being swallowed as "no catalogs"."""
7122+
from specify_cli.workflows.catalog import StepCatalog, StepValidationError
7123+
7124+
config_path = project_dir / ".specify" / "step-catalogs.yml"
7125+
config_path.write_text(body, encoding="utf-8")
7126+
catalog = StepCatalog(project_dir)
7127+
with pytest.raises(StepValidationError, match="'catalogs' must be a list"):
7128+
catalog._load_catalog_config(config_path)
7129+
7130+
@pytest.mark.parametrize(
7131+
"body",
7132+
["", "# only a comment\n", "null\n", "~\n", "catalogs:\n", "catalogs: []\n"],
7133+
)
7134+
def test_empty_or_null_config_is_noop(self, project_dir, body):
7135+
"""An empty document, explicit null, or absent/empty ``catalogs:`` stays a
7136+
valid no-op — the layer contributes nothing and resolution falls
7137+
through."""
7138+
from specify_cli.workflows.catalog import StepCatalog
7139+
7140+
config_path = project_dir / ".specify" / "step-catalogs.yml"
7141+
config_path.write_text(body, encoding="utf-8")
7142+
catalog = StepCatalog(project_dir)
7143+
assert catalog._load_catalog_config(config_path) is None
7144+
70487145
def test_default_catalogs(self, project_dir, monkeypatch):
70497146
from specify_cli.workflows.catalog import StepCatalog
70507147

0 commit comments

Comments
 (0)