Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2688,7 +2688,12 @@ def _load_yaml_config(self, file_path: Path) -> Dict[str, Any]:
return {}

try:
return yaml.safe_load(file_path.read_text(encoding="utf-8")) or {}
data = yaml.safe_load(file_path.read_text(encoding="utf-8"))
# Coerce a non-mapping root (list/scalar, or None for an empty
# file) to {} so callers that iterate/merge the result — e.g.
# _merge_configs' .items() — never crash. Mirrors the same
# non-dict-root guard in get_project_config().
return data if isinstance(data, dict) else {}
except (yaml.YAMLError, OSError, UnicodeError):
return {}

Expand Down
50 changes: 50 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
ExtensionRegistry,
ExtensionManager,
CommandRegistrar,
ConfigManager,
HookExecutor,
ExtensionCatalog,
ExtensionError,
Expand Down Expand Up @@ -7492,3 +7493,52 @@ def fake_open(url, timeout=None, extra_headers=None):
)
assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v1"]


class TestConfigManagerNonMappingYaml:
"""A non-mapping YAML config root must not crash config/hook resolution."""

def _make(self, tmp_path, body: str):
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
ext_dir.mkdir(parents=True)
(ext_dir / "jira-config.yml").write_text(body, encoding="utf-8")
return ConfigManager(tmp_path, "jira")

def test_get_config_coerces_list_root(self, tmp_path):
"""A YAML list root previously raised AttributeError in _merge_configs."""
cm = self._make(tmp_path, "- foo\n- bar\n")
assert cm.get_config() == {}

def test_get_config_coerces_scalar_root(self, tmp_path):
cm = self._make(tmp_path, "just a string\n")
assert cm.get_config() == {}

def test_has_value_and_get_value_do_not_raise(self, tmp_path):
cm = self._make(tmp_path, "- foo\n")
assert cm.has_value("anything") is False
assert cm.get_value("anything") is None

def test_valid_local_config_layers_over_list_root_project_config(self, tmp_path):
"""A malformed project config must not block a valid local config."""
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
ext_dir.mkdir(parents=True)
(ext_dir / "jira-config.yml").write_text("- foo\n- bar\n", encoding="utf-8")
(ext_dir / "local-config.yml").write_text(
"notifications:\n enabled: true\n", encoding="utf-8"
)
cm = ConfigManager(tmp_path, "jira")
assert cm.get_value("notifications.enabled") is True

def test_hook_condition_returns_false_without_raising(self, tmp_path):
"""`config.x is set` on a scalar-root config must evaluate cleanly.

Before the fix, _merge_configs raised AttributeError and the
exception was swallowed by should_execute_hook, silently disabling
every config-based hook for the extension. Assert on
_evaluate_condition directly so the crash isn't masked.
"""
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
ext_dir.mkdir(parents=True)
(ext_dir / "jira-config.yml").write_text("just a string\n", encoding="utf-8")
executor = HookExecutor(tmp_path)
assert executor._evaluate_condition("config.x is set", "jira") is False
Loading