From c06ba2731c50c0bac5eda856f4cee1f66e3d827a Mon Sep 17 00:00:00 2001 From: dr mike Date: Thu, 3 Sep 2026 09:32:43 +0330 Subject: [PATCH] fix(manifest): require tomli on Py3.10 and fail loud when missing Declare tomli with an environment marker and raise ImportError instead of returning None, so missing parsers no longer look like empty manifests. Fixes #3283 --- graphify/manifest_ingest.py | 41 +++++++++++------ pyproject.toml | 1 + tests/test_manifest_tomli_required.py | 64 +++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 14 deletions(-) create mode 100644 tests/test_manifest_tomli_required.py diff --git a/graphify/manifest_ingest.py b/graphify/manifest_ingest.py index 717d9ad9a4..0608eceb4c 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 7fd891dc85..c4d0b7a547 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 0000000000..982dc67824 --- /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