diff --git a/graphify/manifest_ingest.py b/graphify/manifest_ingest.py index 717d9ad9a..0608eceb4 100644 --- a/graphify/manifest_ingest.py +++ b/graphify/manifest_ingest.py @@ -36,6 +36,31 @@ _MAX_MANIFEST_BYTES = 2_000_000 # 2 MB cap — manifests are small; this rejects junk +_TOMLI_REQUIRED = ( + "Package-manifest ingestion on Python < 3.11 needs tomli. " + "Install with: pip install 'tomli' " + "(or reinstall graphifyy, which declares tomli for python_version < '3.11')." +) + + +def _load_toml_module(): + """Return a tomllib-compatible module, or raise ImportError (#3283). + + Returning ``None`` used to look identical to a virtual workspace root with + nothing to emit, so missing ``tomli`` on Python 3.10 silently dropped every + ``Cargo.toml`` / ``pyproject.toml``. Fail loud the way ``cargo_introspect`` + already does. + """ + try: + import tomllib as _toml # type: ignore[import-not-found] + return _toml + except ImportError: + try: + import tomli as _toml # type: ignore[import-not-found,no-redef] + return _toml + except ImportError as exc: + raise ImportError(_TOMLI_REQUIRED) from exc + def is_package_manifest_path(path: Path) -> bool: """True if ``path`` is a recognized package manifest (by filename).""" @@ -182,13 +207,7 @@ def _pep508_name(spec: str) -> str: def _parse_pyproject(text: str) -> dict | None: - try: - import tomllib as _toml - except ImportError: - try: - import tomli as _toml # type: ignore - except ImportError: - return None + _toml = _load_toml_module() data = _toml.loads(text) proj = data.get("project", {}) if isinstance(data.get("project"), dict) else {} poetry = (data.get("tool", {}) or {}).get("poetry", {}) if isinstance(data.get("tool"), dict) else {} @@ -207,13 +226,7 @@ def _parse_cargo(text: str) -> dict | None: """Cargo.toml: name/version from ``[package]``, runtime deps from ``[dependencies]`` plus every ``[target..dependencies]`` table (mirrors ``_parse_pyproject``'s runtime-only scope; dev-/build-dependencies excluded).""" - try: - import tomllib as _toml - except ImportError: # pragma: no cover — Python < 3.11 without tomli only - try: - import tomli as _toml # type: ignore - except ImportError: - return None + _toml = _load_toml_module() data = _toml.loads(text) pkg = data.get("package", {}) if isinstance(data.get("package"), dict) else {} name = pkg.get("name") diff --git a/pyproject.toml b/pyproject.toml index 7fd891dc8..c4d0b7a54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "networkx>=3.4", "numpy>=1.21", "rapidfuzz>=3.0", + "tomli>=2.0.1; python_version < '3.11'", "tree-sitter>=0.23.0,<0.26", "tree-sitter-python>=0.23,<0.26", "tree-sitter-javascript>=0.23,<0.26", diff --git a/tests/test_manifest_tomli_required.py b/tests/test_manifest_tomli_required.py new file mode 100644 index 000000000..982dc6782 --- /dev/null +++ b/tests/test_manifest_tomli_required.py @@ -0,0 +1,64 @@ +"""Regression tests for missing tomli on Python < 3.11 (#3283).""" +from __future__ import annotations + +import builtins +import sys +from pathlib import Path + +import pytest + +from graphify.manifest_ingest import ( + _TOMLI_REQUIRED, + _load_toml_module, + _parse_cargo, + _parse_pyproject, + extract_package_manifest, +) + + +def test_load_toml_module_raises_when_tomli_missing(monkeypatch): + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name in ("tomllib", "tomli"): + raise ImportError(f"blocked {name}") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + with pytest.raises(ImportError, match="tomli"): + _load_toml_module() + + +def test_parse_pyproject_surfaces_missing_parser(monkeypatch): + monkeypatch.setattr( + "graphify.manifest_ingest._load_toml_module", + lambda: (_ for _ in ()).throw(ImportError(_TOMLI_REQUIRED)), + ) + with pytest.raises(ImportError, match="tomli"): + _parse_pyproject('[project]\nname = "x"\n') + + +def test_parse_cargo_surfaces_missing_parser(monkeypatch): + monkeypatch.setattr( + "graphify.manifest_ingest._load_toml_module", + lambda: (_ for _ in ()).throw(ImportError(_TOMLI_REQUIRED)), + ) + with pytest.raises(ImportError, match="tomli"): + _parse_cargo('[package]\nname = "x"\nversion = "0.1.0"\n') + + +def test_extract_package_manifest_reports_missing_tomli(tmp_path, monkeypatch): + p = tmp_path / "pyproject.toml" + p.write_text('[project]\nname = "cool"\nversion = "0.1"\n', encoding="utf-8") + monkeypatch.setattr( + "graphify.manifest_ingest._load_toml_module", + lambda: (_ for _ in ()).throw(ImportError(_TOMLI_REQUIRED)), + ) + result = extract_package_manifest(p) + assert result["nodes"] == [] + assert "tomli" in result.get("error", "").lower() + + +@pytest.mark.skipif(sys.version_info >= (3, 11), reason="stdlib tomllib present") +def test_tomli_is_importable_on_py310(): + import tomli # noqa: F401