From fd88d20766675657906b4f2f2641295d0e4099d4 Mon Sep 17 00:00:00 2001 From: Diego Colombo Date: Tue, 27 Jan 2026 23:49:10 +0000 Subject: [PATCH 1/2] fix: verify dependencies and add consolidation API for install state (issue #193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses microsoft/amplifier#193 - Consolidate install-state.json manipulation. Adds to InstallStateManager: - invalidate_modules_with_missing_deps() - Surgical invalidation checking each tracked module's dependencies against what's actually installed. Returns (checked, invalidated) tuple for reporting. - clear() - Convenience method for full state reset. Also enhances is_installed() to proactively verify dependencies are present, catching stale state after operations like `uv tool install --force` that wipe the venv but don't clear install-state.json. Module-level helpers added: - _extract_dependencies_from_pyproject() - Parses pyproject.toml for deps - _check_dependency_installed() - Uses importlib.metadata for reliable package detection with PEP 503 name normalization This enables amplifier-app-cli to use InstallStateManager instead of direct JSON manipulation in update_executor.py and reset.py. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- amplifier_foundation/modules/install_state.py | 186 +++++++++++++++++- 1 file changed, 185 insertions(+), 1 deletion(-) diff --git a/amplifier_foundation/modules/install_state.py b/amplifier_foundation/modules/install_state.py index 11c4771..d85c088 100644 --- a/amplifier_foundation/modules/install_state.py +++ b/amplifier_foundation/modules/install_state.py @@ -3,13 +3,18 @@ Tracks fingerprints of installed modules to skip redundant `uv pip install` calls. When a module's pyproject.toml/requirements.txt hasn't changed, we can skip the install step entirely, significantly speeding up startup. + +Self-healing: Also verifies that declared dependencies are actually installed. +This catches stale state after `uv tool install --force` wipes the venv. """ from __future__ import annotations import hashlib +import importlib.metadata import json import logging +import re import sys import tempfile from pathlib import Path @@ -17,6 +22,86 @@ logger = logging.getLogger(__name__) +def _extract_dependencies_from_pyproject(pyproject_path: Path) -> list[str]: + """Extract dependency names from a pyproject.toml file. + + Args: + pyproject_path: Path to pyproject.toml file. + + Returns: + List of dependency package names (without version specifiers). + """ + if not pyproject_path.exists(): + return [] + + try: + # Use tomllib (Python 3.11+) or tomli as fallback + try: + import tomllib + except ImportError: + try: + import tomli as tomllib # type: ignore[import-not-found] + except ImportError: + # No TOML parser available - skip dependency check + logger.debug("No TOML parser available, skipping dependency extraction") + return [] + + with open(pyproject_path, "rb") as f: + config = tomllib.load(f) + except Exception as e: + logger.debug(f"Failed to parse {pyproject_path}: {e}") + return [] + + deps = [] + + # Get dependencies from [project.dependencies] + project_deps = config.get("project", {}).get("dependencies", []) + for dep in project_deps: + # Parse dependency string like "aiohttp>=3.8", "requests[security]", or "zope.interface>=5.0" + # Extract the full package name including dots (for namespace packages) + # Stops at: whitespace, extras [...], version specifiers [<>=!~], markers [;], URL [@] + match = re.match(r"^([a-zA-Z0-9._-]+?)(?:\s|\[|[<>=!~;@]|$)", dep) + if match: + deps.append(match.group(1)) + + return deps + + +def _check_dependency_installed(dep_name: str) -> bool: + """Check if a dependency is installed in the current environment. + + Uses importlib.metadata to check by distribution name, which correctly + handles packages where the import name differs from the package name + (e.g., Pillow -> PIL, beautifulsoup4 -> bs4, scikit-learn -> sklearn). + + Args: + dep_name: Package/distribution name (e.g., "aiohttp", "Pillow"). + + Returns: + True if the package is installed, False otherwise. + """ + # Normalize for comparison: PEP 503 says package names are case-insensitive + # and treats hyphens/underscores as equivalent + normalized = dep_name.lower().replace("-", "_").replace(".", "_") + + try: + # Try exact name first + importlib.metadata.distribution(dep_name) + return True + except importlib.metadata.PackageNotFoundError: + pass + + # Try normalized variations (handles case differences and hyphen/underscore) + for variation in [normalized, normalized.replace("_", "-")]: + try: + importlib.metadata.distribution(variation) + return True + except importlib.metadata.PackageNotFoundError: + continue + + return False + + class InstallStateManager: """Tracks module installation state for fast startup. @@ -105,11 +190,17 @@ def _compute_fingerprint(self, module_path: Path) -> str: def is_installed(self, module_path: Path) -> bool: """Check if module is already installed with matching fingerprint. + Also verifies that declared dependencies are actually present in the + Python environment. This catches stale install state after operations + like `uv tool install --force` that wipe the venv but don't clear + the install-state.json file. + Args: module_path: Path to the module directory. Returns: - True if module is installed and fingerprint matches. + True if module is installed, fingerprint matches, AND all + dependencies are actually present. """ path_key = str(module_path.resolve()) entry = self._state["modules"].get(path_key) @@ -127,6 +218,25 @@ def is_installed(self, module_path: Path) -> bool: ) return False + # Self-healing: Verify dependencies are actually installed + # This catches stale state after venv wipe (e.g., uv tool install --force) + pyproject_path = module_path / "pyproject.toml" + deps = _extract_dependencies_from_pyproject(pyproject_path) + + missing_deps = [] + for dep in deps: + if not _check_dependency_installed(dep): + missing_deps.append(dep) + + if missing_deps: + logger.info( + f"Module {module_path.name} has missing dependencies: {missing_deps}. " + f"Will reinstall." + ) + # Invalidate this entry so save() will persist the change + self.invalidate(module_path) + return False + return True def mark_installed(self, module_path: Path) -> None: @@ -191,3 +301,77 @@ def invalidate(self, module_path: Path | None = None) -> None: del self._state["modules"][path_key] self._dirty = True logger.debug(f"Invalidated install state for {module_path.name}") + + def clear(self) -> None: + """Clear all module install state. + + This is a convenience method equivalent to `invalidate(None)`. + Use after operations that may have invalidated the Python environment, + such as `amplifier reset --remove cache`. + + Changes are not persisted until `save()` is called. + """ + self.invalidate(None) + + def invalidate_modules_with_missing_deps(self) -> tuple[int, int]: + """Surgically invalidate only modules whose dependencies are missing. + + Checks each tracked module's declared dependencies against what's + actually installed in the Python environment. Only invalidates entries + for modules that have missing dependencies. + + This is useful after operations like `uv tool install --force` that + recreate the Python environment but don't clear install-state.json. + Modules with all dependencies still satisfied won't be reinstalled. + + Returns: + Tuple of (modules_checked, modules_invalidated). + + Note: + Changes are persisted immediately (calls save() internally). + """ + modules = self._state.get("modules", {}) + if not modules: + logger.debug("No modules in install state to check") + return (0, 0) + + modules_checked = 0 + modules_to_invalidate = [] + + for module_path_str in list(modules.keys()): + module_path = Path(module_path_str) + + # Module directory no longer exists - mark for invalidation + if not module_path.exists(): + modules_to_invalidate.append(module_path_str) + continue + + pyproject_path = module_path / "pyproject.toml" + deps = _extract_dependencies_from_pyproject(pyproject_path) + modules_checked += 1 + + # Check if all dependencies are installed + missing_deps = [] + for dep in deps: + if not _check_dependency_installed(dep): + missing_deps.append(dep) + + if missing_deps: + logger.debug( + f"Module {module_path.name} has missing deps: {missing_deps}" + ) + modules_to_invalidate.append(module_path_str) + + # Remove invalidated entries + for path_str in modules_to_invalidate: + del self._state["modules"][path_str] + module_name = Path(path_str).name + logger.info( + f"Invalidated install state for {module_name} (missing dependencies)" + ) + + if modules_to_invalidate: + self._dirty = True + self.save() + + return (modules_checked, len(modules_to_invalidate)) From 95dc99ddb1aa973d3e2361e457fd7d0a706520ff Mon Sep 17 00:00:00 2001 From: Diego Colombo Date: Wed, 28 Jan 2026 00:15:45 +0000 Subject: [PATCH 2/2] test: add comprehensive tests for InstallStateManager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 35 tests covering: - _extract_dependencies_from_pyproject() helper - Simple deps, version specifiers, extras, namespace packages - Edge cases: nonexistent file, empty deps, invalid TOML - _check_dependency_installed() helper - Installed/uninstalled packages - Case insensitivity and hyphen/underscore normalization - InstallStateManager core functionality - Fresh state creation, loading existing state - Version mismatch and Python executable change handling - Invalid JSON recovery - is_installed() with dependency verification - Fingerprint matching - Dependency presence checking - Auto-invalidation on missing deps - mark_installed(), save(), invalidate() - New methods: clear(), invalidate_modules_with_missing_deps() - Surgical invalidation of modules with missing deps - Mixed module handling (valid + invalid) - Persistence verification 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- tests/test_install_state.py | 532 ++++++++++++++++++++++++++++++++++++ 1 file changed, 532 insertions(+) create mode 100644 tests/test_install_state.py diff --git a/tests/test_install_state.py b/tests/test_install_state.py new file mode 100644 index 0000000..799986f --- /dev/null +++ b/tests/test_install_state.py @@ -0,0 +1,532 @@ +"""Tests for InstallStateManager and related functions.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + + +from amplifier_foundation.modules.install_state import ( + InstallStateManager, + _check_dependency_installed, + _extract_dependencies_from_pyproject, +) + + +class TestExtractDependenciesFromPyproject: + """Tests for _extract_dependencies_from_pyproject helper function.""" + + def test_nonexistent_file_returns_empty(self, tmp_path: Path) -> None: + """Returns empty list when pyproject.toml doesn't exist.""" + result = _extract_dependencies_from_pyproject(tmp_path / "pyproject.toml") + assert result == [] + + def test_extracts_simple_dependencies(self, tmp_path: Path) -> None: + """Extracts simple dependency names without version specifiers.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text(""" +[project] +dependencies = [ + "aiohttp", + "requests", +] +""") + result = _extract_dependencies_from_pyproject(pyproject) + assert result == ["aiohttp", "requests"] + + def test_extracts_dependencies_with_version_specifiers( + self, tmp_path: Path + ) -> None: + """Extracts dependency names, stripping version specifiers.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text(""" +[project] +dependencies = [ + "aiohttp>=3.8", + "requests>=2.28,<3.0", + "pydantic~=2.0", +] +""") + result = _extract_dependencies_from_pyproject(pyproject) + assert result == ["aiohttp", "requests", "pydantic"] + + def test_extracts_dependencies_with_extras(self, tmp_path: Path) -> None: + """Extracts dependency names, stripping extras.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text(""" +[project] +dependencies = [ + "requests[security]", + "httpx[http2]>=0.24", +] +""") + result = _extract_dependencies_from_pyproject(pyproject) + assert result == ["requests", "httpx"] + + def test_extracts_namespace_packages(self, tmp_path: Path) -> None: + """Extracts namespace package names with dots.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text(""" +[project] +dependencies = [ + "zope.interface>=5.0", + "ruamel.yaml", +] +""") + result = _extract_dependencies_from_pyproject(pyproject) + assert result == ["zope.interface", "ruamel.yaml"] + + def test_empty_dependencies_returns_empty(self, tmp_path: Path) -> None: + """Returns empty list when no dependencies declared.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text(""" +[project] +name = "test-package" +""") + result = _extract_dependencies_from_pyproject(pyproject) + assert result == [] + + def test_invalid_toml_returns_empty(self, tmp_path: Path) -> None: + """Returns empty list when TOML is invalid.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text("this is not valid toml {{{{") + result = _extract_dependencies_from_pyproject(pyproject) + assert result == [] + + +class TestCheckDependencyInstalled: + """Tests for _check_dependency_installed helper function.""" + + def test_installed_package_returns_true(self) -> None: + """Returns True for packages that are installed.""" + # pytest is definitely installed since we're running tests + assert _check_dependency_installed("pytest") is True + + def test_uninstalled_package_returns_false(self) -> None: + """Returns False for packages that are not installed.""" + assert _check_dependency_installed("definitely-not-a-real-package-xyz") is False + + def test_case_insensitive_matching(self) -> None: + """Package names are matched case-insensitively.""" + # PyTest vs pytest + assert _check_dependency_installed("PyTest") is True + + def test_hyphen_underscore_normalization(self) -> None: + """Hyphens and underscores are treated as equivalent.""" + # Test with a package we know is installed + # amplifier-core uses hyphens but installs as amplifier_core + with patch("importlib.metadata.distribution") as mock_dist: + # First call with exact name fails, second with normalized succeeds + mock_dist.side_effect = [ + __import__("importlib.metadata").metadata.PackageNotFoundError(), + None, # Success on normalized name + ] + result = _check_dependency_installed("some-package") + assert result is True + + +class TestInstallStateManager: + """Tests for InstallStateManager class.""" + + def test_creates_fresh_state_when_no_file(self, tmp_path: Path) -> None: + """Creates fresh state when install-state.json doesn't exist.""" + mgr = InstallStateManager(tmp_path) + assert mgr._state["version"] == 1 + assert mgr._state["modules"] == {} + + def test_loads_existing_state(self, tmp_path: Path) -> None: + """Loads existing state from disk.""" + import sys + + state_file = tmp_path / "install-state.json" + state_file.write_text( + json.dumps( + { + "version": 1, + "python": sys.executable, + "modules": {"/some/path": {"pyproject_hash": "sha256:abc123"}}, + } + ) + ) + mgr = InstallStateManager(tmp_path) + assert "/some/path" in mgr._state["modules"] + + def test_creates_fresh_state_on_version_mismatch(self, tmp_path: Path) -> None: + """Creates fresh state when version doesn't match.""" + state_file = tmp_path / "install-state.json" + state_file.write_text( + json.dumps( + { + "version": 999, # Wrong version + "python": "/usr/bin/python", + "modules": {"/some/path": {"pyproject_hash": "sha256:abc123"}}, + } + ) + ) + mgr = InstallStateManager(tmp_path) + assert mgr._state["modules"] == {} + + def test_creates_fresh_state_on_python_change(self, tmp_path: Path) -> None: + """Creates fresh state when Python executable changed.""" + state_file = tmp_path / "install-state.json" + state_file.write_text( + json.dumps( + { + "version": 1, + "python": "/different/python", # Different Python + "modules": {"/some/path": {"pyproject_hash": "sha256:abc123"}}, + } + ) + ) + mgr = InstallStateManager(tmp_path) + assert mgr._state["modules"] == {} + + def test_creates_fresh_state_on_invalid_json(self, tmp_path: Path) -> None: + """Creates fresh state when JSON is invalid.""" + state_file = tmp_path / "install-state.json" + state_file.write_text("not valid json {{{") + mgr = InstallStateManager(tmp_path) + assert mgr._state["modules"] == {} + + +class TestInstallStateManagerIsInstalled: + """Tests for InstallStateManager.is_installed method.""" + + def test_returns_false_when_not_tracked(self, tmp_path: Path) -> None: + """Returns False when module is not in state.""" + mgr = InstallStateManager(tmp_path) + module_path = tmp_path / "some-module" + module_path.mkdir() + assert mgr.is_installed(module_path) is False + + def test_returns_true_when_fingerprint_matches(self, tmp_path: Path) -> None: + """Returns True when fingerprint matches and no deps to check.""" + module_path = tmp_path / "some-module" + module_path.mkdir() + # No pyproject.toml means no deps to check + + mgr = InstallStateManager(tmp_path) + mgr.mark_installed(module_path) + + assert mgr.is_installed(module_path) is True + + def test_returns_false_when_fingerprint_changes(self, tmp_path: Path) -> None: + """Returns False when pyproject.toml content changed.""" + module_path = tmp_path / "some-module" + module_path.mkdir() + + pyproject = module_path / "pyproject.toml" + pyproject.write_text("[project]\nname = 'test'\n") + + mgr = InstallStateManager(tmp_path) + mgr.mark_installed(module_path) + + # Change the pyproject.toml + pyproject.write_text("[project]\nname = 'test'\nversion = '2.0'\n") + + assert mgr.is_installed(module_path) is False + + def test_returns_false_when_dependency_missing(self, tmp_path: Path) -> None: + """Returns False when a declared dependency is not installed.""" + module_path = tmp_path / "some-module" + module_path.mkdir() + + pyproject = module_path / "pyproject.toml" + pyproject.write_text(""" +[project] +name = "test" +dependencies = ["definitely-not-installed-package-xyz"] +""") + + mgr = InstallStateManager(tmp_path) + mgr.mark_installed(module_path) + + # Should return False because dependency is missing + assert mgr.is_installed(module_path) is False + + def test_returns_true_when_all_dependencies_present(self, tmp_path: Path) -> None: + """Returns True when all declared dependencies are installed.""" + module_path = tmp_path / "some-module" + module_path.mkdir() + + pyproject = module_path / "pyproject.toml" + pyproject.write_text(""" +[project] +name = "test" +dependencies = ["pytest"] +""") + + mgr = InstallStateManager(tmp_path) + mgr.mark_installed(module_path) + + # Should return True because pytest is installed + assert mgr.is_installed(module_path) is True + + def test_invalidates_entry_when_dependency_missing(self, tmp_path: Path) -> None: + """Invalidates the state entry when dependency is missing.""" + module_path = tmp_path / "some-module" + module_path.mkdir() + + pyproject = module_path / "pyproject.toml" + pyproject.write_text(""" +[project] +name = "test" +dependencies = ["definitely-not-installed-xyz"] +""") + + mgr = InstallStateManager(tmp_path) + mgr.mark_installed(module_path) + path_key = str(module_path.resolve()) + + # Verify it's tracked + assert path_key in mgr._state["modules"] + + # Check is_installed (should return False and invalidate) + assert mgr.is_installed(module_path) is False + + # Verify it was invalidated + assert path_key not in mgr._state["modules"] + + +class TestInstallStateManagerMarkInstalled: + """Tests for InstallStateManager.mark_installed method.""" + + def test_marks_module_as_installed(self, tmp_path: Path) -> None: + """Records module as installed with fingerprint.""" + module_path = tmp_path / "some-module" + module_path.mkdir() + + mgr = InstallStateManager(tmp_path) + mgr.mark_installed(module_path) + + path_key = str(module_path.resolve()) + assert path_key in mgr._state["modules"] + assert "pyproject_hash" in mgr._state["modules"][path_key] + + def test_computes_fingerprint_from_pyproject(self, tmp_path: Path) -> None: + """Computes fingerprint from pyproject.toml content.""" + module_path = tmp_path / "some-module" + module_path.mkdir() + (module_path / "pyproject.toml").write_text("[project]\nname = 'test'\n") + + mgr = InstallStateManager(tmp_path) + mgr.mark_installed(module_path) + + path_key = str(module_path.resolve()) + assert mgr._state["modules"][path_key]["pyproject_hash"].startswith("sha256:") + + +class TestInstallStateManagerSave: + """Tests for InstallStateManager.save method.""" + + def test_saves_state_to_disk(self, tmp_path: Path) -> None: + """Persists state to disk.""" + module_path = tmp_path / "some-module" + module_path.mkdir() + + mgr = InstallStateManager(tmp_path) + mgr.mark_installed(module_path) + mgr.save() + + state_file = tmp_path / "install-state.json" + assert state_file.exists() + + data = json.loads(state_file.read_text()) + assert str(module_path.resolve()) in data["modules"] + + def test_no_op_when_not_dirty(self, tmp_path: Path) -> None: + """Does nothing when state hasn't changed.""" + state_file = tmp_path / "install-state.json" + + mgr = InstallStateManager(tmp_path) + mgr._dirty = False + mgr.save() + + # File might exist from init, but check it wasn't modified + # by verifying a second save also does nothing + if state_file.exists(): + mtime = state_file.stat().st_mtime + mgr.save() + assert state_file.stat().st_mtime == mtime + + +class TestInstallStateManagerInvalidate: + """Tests for InstallStateManager.invalidate method.""" + + def test_invalidates_specific_module(self, tmp_path: Path) -> None: + """Removes state for a specific module.""" + module1 = tmp_path / "module1" + module1.mkdir() + module2 = tmp_path / "module2" + module2.mkdir() + + mgr = InstallStateManager(tmp_path) + mgr.mark_installed(module1) + mgr.mark_installed(module2) + + mgr.invalidate(module1) + + assert str(module1.resolve()) not in mgr._state["modules"] + assert str(module2.resolve()) in mgr._state["modules"] + + def test_invalidates_all_modules_when_none(self, tmp_path: Path) -> None: + """Removes state for all modules when path is None.""" + module1 = tmp_path / "module1" + module1.mkdir() + module2 = tmp_path / "module2" + module2.mkdir() + + mgr = InstallStateManager(tmp_path) + mgr.mark_installed(module1) + mgr.mark_installed(module2) + + mgr.invalidate(None) + + assert mgr._state["modules"] == {} + + +class TestInstallStateManagerClear: + """Tests for InstallStateManager.clear method.""" + + def test_clears_all_modules(self, tmp_path: Path) -> None: + """Clears all module state.""" + module1 = tmp_path / "module1" + module1.mkdir() + module2 = tmp_path / "module2" + module2.mkdir() + + mgr = InstallStateManager(tmp_path) + mgr.mark_installed(module1) + mgr.mark_installed(module2) + + mgr.clear() + + assert mgr._state["modules"] == {} + assert mgr._dirty is True + + +class TestInstallStateManagerInvalidateModulesWithMissingDeps: + """Tests for InstallStateManager.invalidate_modules_with_missing_deps method.""" + + def test_returns_zero_when_no_modules(self, tmp_path: Path) -> None: + """Returns (0, 0) when no modules tracked.""" + mgr = InstallStateManager(tmp_path) + checked, invalidated = mgr.invalidate_modules_with_missing_deps() + assert checked == 0 + assert invalidated == 0 + + def test_invalidates_module_with_missing_dep(self, tmp_path: Path) -> None: + """Invalidates module when dependency is missing.""" + module_path = tmp_path / "some-module" + module_path.mkdir() + (module_path / "pyproject.toml").write_text(""" +[project] +name = "test" +dependencies = ["definitely-not-installed-xyz"] +""") + + mgr = InstallStateManager(tmp_path) + mgr.mark_installed(module_path) + mgr.save() + + checked, invalidated = mgr.invalidate_modules_with_missing_deps() + + assert checked == 1 + assert invalidated == 1 + assert str(module_path.resolve()) not in mgr._state["modules"] + + def test_keeps_module_with_all_deps_present(self, tmp_path: Path) -> None: + """Keeps module when all dependencies are present.""" + module_path = tmp_path / "some-module" + module_path.mkdir() + (module_path / "pyproject.toml").write_text(""" +[project] +name = "test" +dependencies = ["pytest"] +""") + + mgr = InstallStateManager(tmp_path) + mgr.mark_installed(module_path) + mgr.save() + + checked, invalidated = mgr.invalidate_modules_with_missing_deps() + + assert checked == 1 + assert invalidated == 0 + assert str(module_path.resolve()) in mgr._state["modules"] + + def test_invalidates_module_with_nonexistent_path(self, tmp_path: Path) -> None: + """Invalidates module when its directory no longer exists.""" + import sys + + # Directly inject a state entry for a non-existent path + mgr = InstallStateManager(tmp_path) + mgr._state["modules"]["/nonexistent/path"] = {"pyproject_hash": "sha256:abc"} + mgr._dirty = True + mgr.save() + + # Reload and check + mgr2 = InstallStateManager(tmp_path) + # Fix the python path to match current + mgr2._state["python"] = sys.executable + checked, invalidated = mgr2.invalidate_modules_with_missing_deps() + + assert invalidated == 1 + assert "/nonexistent/path" not in mgr2._state["modules"] + + def test_persists_changes_immediately(self, tmp_path: Path) -> None: + """Saves changes to disk immediately after invalidation.""" + module_path = tmp_path / "some-module" + module_path.mkdir() + (module_path / "pyproject.toml").write_text(""" +[project] +name = "test" +dependencies = ["not-installed-xyz"] +""") + + mgr = InstallStateManager(tmp_path) + mgr.mark_installed(module_path) + mgr.save() + + mgr.invalidate_modules_with_missing_deps() + + # Reload from disk and verify change was persisted + mgr2 = InstallStateManager(tmp_path) + assert str(module_path.resolve()) not in mgr2._state["modules"] + + def test_handles_mixed_modules(self, tmp_path: Path) -> None: + """Correctly handles mix of valid and invalid modules.""" + # Module with missing dep + bad_module = tmp_path / "bad-module" + bad_module.mkdir() + (bad_module / "pyproject.toml").write_text(""" +[project] +dependencies = ["not-installed-xyz"] +""") + + # Module with present dep + good_module = tmp_path / "good-module" + good_module.mkdir() + (good_module / "pyproject.toml").write_text(""" +[project] +dependencies = ["pytest"] +""") + + # Module with no deps + nodeps_module = tmp_path / "nodeps-module" + nodeps_module.mkdir() + + mgr = InstallStateManager(tmp_path) + mgr.mark_installed(bad_module) + mgr.mark_installed(good_module) + mgr.mark_installed(nodeps_module) + mgr.save() + + checked, invalidated = mgr.invalidate_modules_with_missing_deps() + + assert checked == 3 + assert invalidated == 1 + assert str(bad_module.resolve()) not in mgr._state["modules"] + assert str(good_module.resolve()) in mgr._state["modules"] + assert str(nodeps_module.resolve()) in mgr._state["modules"]