diff --git a/src/specify_cli/commands/bundle/__init__.py b/src/specify_cli/commands/bundle/__init__.py index 7476cb41b5..10df8aca14 100644 --- a/src/specify_cli/commands/bundle/__init__.py +++ b/src/specify_cli/commands/bundle/__init__.py @@ -771,7 +771,17 @@ def _local_manifest_source(arg: str): error_type=BundlerError, label="bundle manifest", ) - data = _yaml.safe_load(io.BytesIO(raw)) + try: + data = _yaml.safe_load(io.BytesIO(raw)) + except _yaml.YAMLError as exc: + # The sibling directory/bundle.yml branches reach YAML through + # load_yaml(), which turns a parse failure into a BundlerError. This + # branch parses inline, so without this it raises a raw YAMLError -- + # neither a ValueError nor an OSError -- which escapes + # bundle_install()'s `except BundlerError` as a traceback. + raise BundlerError( + f"Invalid YAML in bundle.yml inside '{candidate}': {exc}" + ) from exc return BundleManifest.from_dict(data) if candidate.name == "bundle.yml" or candidate.suffix in (".yml", ".yaml"): diff --git a/tests/integration/test_bundler_local_install.py b/tests/integration/test_bundler_local_install.py index 164de57006..5ca873c78a 100644 --- a/tests/integration/test_bundler_local_install.py +++ b/tests/integration/test_bundler_local_install.py @@ -186,6 +186,50 @@ def test_local_zip_uses_bounded_archive_open(tmp_path: Path): _local_manifest_source(str(artifact)) +def test_local_zip_wraps_malformed_manifest_yaml(tmp_path: Path): + """A malformed bundle.yml inside a .zip must raise BundlerError. + + The zip branch parses YAML inline rather than through load_yaml(), so the + raw yaml.YAMLError used to escape. It is neither a ValueError nor an + OSError, so nothing upstream caught it. + """ + artifact = tmp_path / "bad-manifest.zip" + with zipfile.ZipFile(artifact, "w") as archive: + archive.writestr("bundle.yml", "bundle: [unclosed\n id: demo\n") + + with pytest.raises(BundlerError, match="Invalid YAML"): + _local_manifest_source(str(artifact)) + + +def test_malformed_manifest_yaml_fails_alike_for_every_local_source(tmp_path: Path): + """`bundle install` reports malformed YAML the same way for all 3 sources. + + Directory and bundle.yml sources already exited 1 with an "Invalid YAML" + message; the .zip source dumped a yaml.parser.ParserError traceback. + """ + bad_yaml = "bundle: [unclosed\n id: demo\n" + + directory = tmp_path / "dir-src" + directory.mkdir() + (directory / "bundle.yml").write_text(bad_yaml, encoding="utf-8") + + manifest_file = tmp_path / "standalone.yml" + manifest_file.write_text(bad_yaml, encoding="utf-8") + + artifact = tmp_path / "artifact.zip" + with zipfile.ZipFile(artifact, "w") as archive: + archive.writestr("bundle.yml", bad_yaml) + + runner = CliRunner() + for source in (directory, manifest_file, artifact): + result = runner.invoke(app, ["bundle", "install", str(source)]) + assert result.exit_code == 1, f"{source.name}: {result.output}" + assert result.exception is None or isinstance( + result.exception, SystemExit + ), f"{source.name} leaked {type(result.exception).__name__}" + assert "Invalid YAML" in result.output, f"{source.name}: {result.output}" + + def test_invalid_local_manifest_is_rejected_before_project_init( tmp_path: Path, monkeypatch,