diff --git a/CHANGES.md b/CHANGES.md index 07187a9..ac1f3e8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,11 @@ fully managed by mxdev. Disable via `uv-constraint-dependencies = false` in `[settings]`. Inspired by Maik Derstappen's `uv-import-constraint-dependencies`. [jensens] +- Fix #83: Remove stale entries from `[tool.uv.sources]` when a package is removed from + `mx.ini` (or switched to `install-mode = skip`). mxdev tags the source entries it writes + with a `# managed by mxdev` marker and prunes managed entries that are no longer + configured, while leaving user-defined sources untouched. [jensens] + ## 5.3.2 (2026-05-30) diff --git a/README.md b/README.md index bd3b680..6250efe 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,9 @@ managed = true ``` mxdev will automatically inject the local VCS paths of your developed packages into `[tool.uv.sources]`. +Each entry mxdev writes is tagged with a `# managed by mxdev` marker comment. On every run mxdev +reconciles these: entries whose package was removed from `mx.ini` (or switched to `install-mode = skip`) +are pruned, while any user-defined sources you added yourself are left untouched. Any `version-overrides` declared in `mx.ini` are also written to `[tool.uv] override-dependencies`. For example: ```ini diff --git a/src/mxdev/uv.py b/src/mxdev/uv.py index e6e171e..e34b598 100644 --- a/src/mxdev/uv.py +++ b/src/mxdev/uv.py @@ -2,6 +2,7 @@ from mxdev.hooks import Hook from mxdev.state import State from pathlib import Path +from typing import Any from typing import TYPE_CHECKING import logging @@ -15,6 +16,17 @@ logger = logging.getLogger("mxdev") +# Trailing comment used to tag the [tool.uv.sources] entries mxdev writes, so +# stale ones can be pruned without touching user-defined sources. +_UV_SOURCE_MARKER = "managed by mxdev" + + +def _is_mxdev_managed_source(value: Any) -> bool: + """Return True if a [tool.uv.sources] value carries the mxdev marker comment.""" + trivia = getattr(value, "trivia", None) + comment = getattr(trivia, "comment", "") or "" + return _UV_SOURCE_MARKER in comment + def _constraints_to_uv(constraints: list[str]) -> list[tuple[str, str]]: """Turn resolved constraint lines into ordered uv array items. @@ -138,31 +150,35 @@ def _update_pyproject(self, doc: "tomlkit.TOMLDocument", state: State) -> None: write_constraints = to_bool(settings.get("uv-constraint-dependencies", "true")) constraint_items = _constraints_to_uv(state.constraints) if write_constraints else [] - if not packages and not overrides and not constraint_items: - # Nothing to add. The only reason to continue is to drop a stale - # mxdev-managed constraint-dependencies array when the feature is on. - uv_table = doc.get("tool", {}).get("uv") - if not write_constraints or uv_table is None or "constraint-dependencies" not in uv_table: - return + # Packages mxdev manages as path sources. A package in "skip" install-mode + # gets no source entry (and an existing one is pruned below). + managed_sources = {name: data for name, data in packages.items() if data.get("install-mode") != "skip"} if "tool" not in doc: doc.add("tool", tomlkit.table()) if "uv" not in doc["tool"]: doc["tool"]["uv"] = tomlkit.table() - - # 1. Update [tool.uv.sources] - if packages: - if "sources" not in doc["tool"]["uv"]: - doc["tool"]["uv"]["sources"] = tomlkit.table() - - uv_sources = doc["tool"]["uv"]["sources"] - - for pkg_name, pkg_data in packages.items(): + uv = doc["tool"]["uv"] + + # 1. Reconcile [tool.uv.sources]: write the current managed sources and + # prune mxdev-managed entries whose package was removed from mx.ini. + # Foreign (user-defined) sources without the mxdev marker are never + # touched. + existing_sources = uv.get("sources") + if managed_sources or existing_sources is not None: + if existing_sources is None: + uv["sources"] = tomlkit.table() + uv_sources = uv["sources"] + + # Prune stale mxdev-managed entries. + for key in list(uv_sources.keys()): + if _is_mxdev_managed_source(uv_sources[key]) and key not in managed_sources: + del uv_sources[key] + + # Write / refresh current managed entries, each tagged with the marker. + for pkg_name, pkg_data in managed_sources.items(): install_mode = pkg_data.get("install-mode", "editable") - if install_mode == "skip": - continue - target_dir = Path(pkg_data.get("target", "sources")) package_path = target_dir / pkg_name subdirectory = pkg_data.get("subdirectory", "") @@ -186,13 +202,19 @@ def _update_pyproject(self, doc: "tomlkit.TOMLDocument", state: State) -> None: source_table.append("editable", False) uv_sources[pkg_name] = source_table + uv_sources[pkg_name].trivia.comment_ws = " " + uv_sources[pkg_name].trivia.comment = f"# {_UV_SOURCE_MARKER}" + + # Drop the table entirely if reconciliation emptied it. + if len(uv_sources) == 0: + del uv["sources"] # 2. Update [tool.uv] override-dependencies from version-overrides if overrides: override_array = tomlkit.array() override_array.extend(overrides.values()) override_array.multiline(True) - doc["tool"]["uv"]["override-dependencies"] = override_array + uv["override-dependencies"] = override_array # 3. Update [tool.uv] constraint-dependencies from resolved constraints if write_constraints: @@ -205,7 +227,7 @@ def _update_pyproject(self, doc: "tomlkit.TOMLDocument", state: State) -> None: constraint_array.add_line(comment=text) else: constraint_array.add_line(text) - doc["tool"]["uv"]["constraint-dependencies"] = constraint_array - elif "constraint-dependencies" in doc["tool"]["uv"]: + uv["constraint-dependencies"] = constraint_array + elif "constraint-dependencies" in uv: # Resolved set is empty: drop a stale mxdev-managed array. - del doc["tool"]["uv"]["constraint-dependencies"] + del uv["constraint-dependencies"] diff --git a/tests/test_uv.py b/tests/test_uv.py index 7ebe3db..5f2561c 100644 --- a/tests/test_uv.py +++ b/tests/test_uv.py @@ -635,3 +635,93 @@ def test_end_to_end_constraint_chain(tmp_path, monkeypatch): assert "AccessControl==7.3" not in cdeps # The override itself is carried by override-dependencies. assert list(doc["tool"]["uv"]["override-dependencies"]) == ["AccessControl==7.4"] + + +def _write_mx_ini_packages(tmp_path, *names): + lines = ["[settings]"] + for name in names: + lines.append(f"[{name}]") + lines.append(f"url = https://example.com/{name}.git") + lines.append("target = sources") + lines.append("install-mode = editable") + (tmp_path / "mx.ini").write_text("\n".join(lines) + "\n") + + +def _run_hook(tmp_path): + config = Configuration("mx.ini") + state = State(config) + UvPyprojectUpdater().write(state) + return tomlkit.parse((tmp_path / "pyproject.toml").read_text()) + + +def test_drops_source_when_package_removed_from_mx_ini(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "backend"\ndependencies = []\n\n[tool.uv]\nmanaged = true\n' + ) + + # Phase 1: two packages -> both written to [tool.uv.sources] + _write_mx_ini_packages(tmp_path, "addon-a", "addon-b") + doc = _run_hook(tmp_path) + assert "addon-a" in doc["tool"]["uv"]["sources"] + assert "addon-b" in doc["tool"]["uv"]["sources"] + + # Phase 2: addon-b removed from mx.ini -> must be removed from pyproject.toml + _write_mx_ini_packages(tmp_path, "addon-a") + doc = _run_hook(tmp_path) + assert "addon-a" in doc["tool"]["uv"]["sources"] + assert "addon-b" not in doc["tool"]["uv"]["sources"] + + +def test_preserves_foreign_sources_when_reconciling(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + # A hand-written, non-mxdev source must never be touched. + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "backend"\ndependencies = []\n\n' + "[tool.uv]\nmanaged = true\n\n" + "[tool.uv.sources]\n" + 'my-fork = { git = "https://github.com/me/my-fork.git", branch = "main" }\n' + ) + + _write_mx_ini_packages(tmp_path, "addon-a") + doc = _run_hook(tmp_path) + assert "addon-a" in doc["tool"]["uv"]["sources"] + assert "my-fork" in doc["tool"]["uv"]["sources"] + + # Drop addon-a: it goes away, the foreign source stays. + (tmp_path / "mx.ini").write_text("[settings]\n") + doc = _run_hook(tmp_path) + assert "addon-a" not in doc["tool"]["uv"]["sources"] + assert "my-fork" in doc["tool"]["uv"]["sources"] + + +def test_skip_install_mode_removes_existing_source(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "backend"\ndependencies = []\n\n[tool.uv]\nmanaged = true\n' + ) + + _write_mx_ini_packages(tmp_path, "addon-a") + doc = _run_hook(tmp_path) + assert "addon-a" in doc["tool"]["uv"]["sources"] + + # Switch addon-a to skip -> its source must be removed. + (tmp_path / "mx.ini").write_text( + "[settings]\n[addon-a]\nurl = https://example.com/addon-a.git\n" "target = sources\ninstall-mode = skip\n" + ) + doc = _run_hook(tmp_path) + assert "addon-a" not in doc["tool"]["uv"].get("sources", {}) + + +def test_source_reconcile_idempotency(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "backend"\ndependencies = []\n\n[tool.uv]\nmanaged = true\n' + ) + _write_mx_ini_packages(tmp_path, "addon-a", "addon-b") + + UvPyprojectUpdater().write(State(Configuration("mx.ini"))) + first = (tmp_path / "pyproject.toml").read_text() + UvPyprojectUpdater().write(State(Configuration("mx.ini"))) + second = (tmp_path / "pyproject.toml").read_text() + assert first == second