diff --git a/pm/install.py b/pm/install.py index a1316de627..fbea7b9d7d 100644 --- a/pm/install.py +++ b/pm/install.py @@ -542,6 +542,22 @@ def _member_inputs(plugins: PluginInput | None) -> dict: raise TypeError(f"{type(plugins).__name__} changes plugin state; only a sync may carry it") +def _still_declared(package, recorded: list[str]) -> list[str]: + """The recorded extras this tree still declares. + + An extra the source removed (``hindsight``) would otherwise ride the ledger + into every later ``uv sync`` and fail it with "Extra is not defined". Only + recorded extras are pruned; an explicitly requested unknown extra still fails. + """ + from pm.features import declared_extras + + root = package.project_root() + if not (root / "pyproject.toml").is_file(): + return list(recorded) + declared = set(declared_extras(root)) + return [extra for extra in recorded if extra in declared] + + def venv_is_current(*, extras: list[str] | None = None, plugins: Members | Candidates | None = None, project_root: Path | None = None) -> bool: """Probe the requested union without changing recorded dependency state.""" @@ -559,7 +575,7 @@ def venv_is_current(*, extras: list[str] | None = None, plugins: Members | Candi or not isinstance(fact.get("extras"), list) or any(not isinstance(extra, str) for extra in fact["extras"])): raise ValueError("invalid recorded dependency state") - enabled = sorted(set(fact["extras"]) | set(extras or [])) + enabled = sorted(set(_still_declared(package, fact["extras"])) | set(extras or [])) stamp = package.expected_stamp(enabled, **_member_inputs(plugins)) return _runtime_state_matches(fact, stamp, project_root=root) @@ -651,7 +667,7 @@ def _target_selection(package, fact: dict, *, extras, inputs: dict, repair: bool return enabled, stamp, {"repair": True} # The first writable generation replaces, rather than layers on, # the payload. Retain its extras until a recorded selection owns them. - enabled = sorted(set(fact.get("extras", shipped or [])) | set(extras or [])) + enabled = sorted(set(_still_declared(package, fact.get("extras", shipped or []))) | set(extras or [])) return enabled, package.expected_stamp(enabled, **inputs), inputs diff --git a/tests/hermes_cli/test_venv_sync_currency.py b/tests/hermes_cli/test_venv_sync_currency.py index 0b43195e74..b103f659fa 100644 --- a/tests/hermes_cli/test_venv_sync_currency.py +++ b/tests/hermes_cli/test_venv_sync_currency.py @@ -59,6 +59,10 @@ def test_check_uses_real_pm_selection_and_keeps_invalid_evidence(admission_env, config.write_bytes(old_config) check('current') + # A recorded extra only counts while the tree still declares it. + pyproject = core / 'pyproject.toml' + pyproject.write_text(pyproject.read_text(encoding='utf-8') + + '[project.optional-dependencies]\nchanged-extra = []\n', encoding='utf-8') altered = json.loads(pristine) altered['packages']['venv']['extras'] = ['changed-extra'] facts_path.write_text(json.dumps(altered), encoding='utf-8') diff --git a/tests/pm/test_source_update_launch.py b/tests/pm/test_source_update_launch.py index 7f8ecb0386..0bd0a36a02 100644 --- a/tests/pm/test_source_update_launch.py +++ b/tests/pm/test_source_update_launch.py @@ -329,6 +329,24 @@ def test_failed_real_sync_preserves_previous_selection_and_retries(source_launch assert not any(marker.exists() for marker in markers) +@pytest.mark.platforms("posix") +def test_source_update_that_removes_a_recorded_extra_still_syncs(source_launch): + """A dropped extra (hindsight, 73c598e319) must not brick every later sync.""" + root, store_python, _ = source_launch + pm.sync_venv(["all", "launch-extra"], explicit=True, project_root=root) + assert _fact(root)["extras"] == ["all", "launch-extra"] + + pyproject = root / "pyproject.toml" + pyproject.write_text(pyproject.read_text(encoding="utf-8").replace("launch-extra = []\n", ""), + encoding="utf-8") + pm.lock_project(root, offline=True, explicit=True) + assert not pm.venv_is_current(project_root=root) + + assert venv_sync.prepare_launch(root, []) == store_python + assert _fact(root)["extras"] == ["all"] + assert pm.venv_is_current(project_root=root) + + @pytest.mark.platforms("posix") @pytest.mark.parametrize("mode", ["script", "module", "command"]) def test_real_bootstrap_reexecs_before_app_imports(source_launch, tmp_path, isolated_python, mode):