Skip to content

Commit da0b792

Browse files
authored
Fix #83: remove stale entries from [tool.uv.sources] (#91)
* Add failing tests for issue #83 (stale [tool.uv.sources] not removed) * Fix #83: prune stale [tool.uv.sources] entries mxdev tags each source entry it writes with a '# managed by mxdev' marker comment and reconciles them on every run: managed entries whose package was removed from mx.ini (or switched to install-mode = skip) are pruned, while user-defined sources without the marker are left untouched.
1 parent db96d41 commit da0b792

4 files changed

Lines changed: 142 additions & 22 deletions

File tree

CHANGES.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
fully managed by mxdev. Disable via `uv-constraint-dependencies = false` in `[settings]`.
99
Inspired by Maik Derstappen's `uv-import-constraint-dependencies`. [jensens]
1010

11+
- Fix #83: Remove stale entries from `[tool.uv.sources]` when a package is removed from
12+
`mx.ini` (or switched to `install-mode = skip`). mxdev tags the source entries it writes
13+
with a `# managed by mxdev` marker and prunes managed entries that are no longer
14+
configured, while leaving user-defined sources untouched. [jensens]
15+
1116

1217
## 5.3.2 (2026-05-30)
1318

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,9 @@ managed = true
311311
```
312312

313313
mxdev will automatically inject the local VCS paths of your developed packages into `[tool.uv.sources]`.
314+
Each entry mxdev writes is tagged with a `# managed by mxdev` marker comment. On every run mxdev
315+
reconciles these: entries whose package was removed from `mx.ini` (or switched to `install-mode = skip`)
316+
are pruned, while any user-defined sources you added yourself are left untouched.
314317

315318
Any `version-overrides` declared in `mx.ini` are also written to `[tool.uv] override-dependencies`. For example:
316319
```ini

src/mxdev/uv.py

Lines changed: 44 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from mxdev.hooks import Hook
33
from mxdev.state import State
44
from pathlib import Path
5+
from typing import Any
56
from typing import TYPE_CHECKING
67

78
import logging
@@ -15,6 +16,17 @@
1516

1617
logger = logging.getLogger("mxdev")
1718

19+
# Trailing comment used to tag the [tool.uv.sources] entries mxdev writes, so
20+
# stale ones can be pruned without touching user-defined sources.
21+
_UV_SOURCE_MARKER = "managed by mxdev"
22+
23+
24+
def _is_mxdev_managed_source(value: Any) -> bool:
25+
"""Return True if a [tool.uv.sources] value carries the mxdev marker comment."""
26+
trivia = getattr(value, "trivia", None)
27+
comment = getattr(trivia, "comment", "") or ""
28+
return _UV_SOURCE_MARKER in comment
29+
1830

1931
def _constraints_to_uv(constraints: list[str]) -> list[tuple[str, str]]:
2032
"""Turn resolved constraint lines into ordered uv array items.
@@ -138,31 +150,35 @@ def _update_pyproject(self, doc: "tomlkit.TOMLDocument", state: State) -> None:
138150
write_constraints = to_bool(settings.get("uv-constraint-dependencies", "true"))
139151
constraint_items = _constraints_to_uv(state.constraints) if write_constraints else []
140152

141-
if not packages and not overrides and not constraint_items:
142-
# Nothing to add. The only reason to continue is to drop a stale
143-
# mxdev-managed constraint-dependencies array when the feature is on.
144-
uv_table = doc.get("tool", {}).get("uv")
145-
if not write_constraints or uv_table is None or "constraint-dependencies" not in uv_table:
146-
return
153+
# Packages mxdev manages as path sources. A package in "skip" install-mode
154+
# gets no source entry (and an existing one is pruned below).
155+
managed_sources = {name: data for name, data in packages.items() if data.get("install-mode") != "skip"}
147156

148157
if "tool" not in doc:
149158
doc.add("tool", tomlkit.table())
150159
if "uv" not in doc["tool"]:
151160
doc["tool"]["uv"] = tomlkit.table()
152-
153-
# 1. Update [tool.uv.sources]
154-
if packages:
155-
if "sources" not in doc["tool"]["uv"]:
156-
doc["tool"]["uv"]["sources"] = tomlkit.table()
157-
158-
uv_sources = doc["tool"]["uv"]["sources"]
159-
160-
for pkg_name, pkg_data in packages.items():
161+
uv = doc["tool"]["uv"]
162+
163+
# 1. Reconcile [tool.uv.sources]: write the current managed sources and
164+
# prune mxdev-managed entries whose package was removed from mx.ini.
165+
# Foreign (user-defined) sources without the mxdev marker are never
166+
# touched.
167+
existing_sources = uv.get("sources")
168+
if managed_sources or existing_sources is not None:
169+
if existing_sources is None:
170+
uv["sources"] = tomlkit.table()
171+
uv_sources = uv["sources"]
172+
173+
# Prune stale mxdev-managed entries.
174+
for key in list(uv_sources.keys()):
175+
if _is_mxdev_managed_source(uv_sources[key]) and key not in managed_sources:
176+
del uv_sources[key]
177+
178+
# Write / refresh current managed entries, each tagged with the marker.
179+
for pkg_name, pkg_data in managed_sources.items():
161180
install_mode = pkg_data.get("install-mode", "editable")
162181

163-
if install_mode == "skip":
164-
continue
165-
166182
target_dir = Path(pkg_data.get("target", "sources"))
167183
package_path = target_dir / pkg_name
168184
subdirectory = pkg_data.get("subdirectory", "")
@@ -186,13 +202,19 @@ def _update_pyproject(self, doc: "tomlkit.TOMLDocument", state: State) -> None:
186202
source_table.append("editable", False)
187203

188204
uv_sources[pkg_name] = source_table
205+
uv_sources[pkg_name].trivia.comment_ws = " "
206+
uv_sources[pkg_name].trivia.comment = f"# {_UV_SOURCE_MARKER}"
207+
208+
# Drop the table entirely if reconciliation emptied it.
209+
if len(uv_sources) == 0:
210+
del uv["sources"]
189211

190212
# 2. Update [tool.uv] override-dependencies from version-overrides
191213
if overrides:
192214
override_array = tomlkit.array()
193215
override_array.extend(overrides.values())
194216
override_array.multiline(True)
195-
doc["tool"]["uv"]["override-dependencies"] = override_array
217+
uv["override-dependencies"] = override_array
196218

197219
# 3. Update [tool.uv] constraint-dependencies from resolved constraints
198220
if write_constraints:
@@ -205,7 +227,7 @@ def _update_pyproject(self, doc: "tomlkit.TOMLDocument", state: State) -> None:
205227
constraint_array.add_line(comment=text)
206228
else:
207229
constraint_array.add_line(text)
208-
doc["tool"]["uv"]["constraint-dependencies"] = constraint_array
209-
elif "constraint-dependencies" in doc["tool"]["uv"]:
230+
uv["constraint-dependencies"] = constraint_array
231+
elif "constraint-dependencies" in uv:
210232
# Resolved set is empty: drop a stale mxdev-managed array.
211-
del doc["tool"]["uv"]["constraint-dependencies"]
233+
del uv["constraint-dependencies"]

tests/test_uv.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,3 +635,93 @@ def test_end_to_end_constraint_chain(tmp_path, monkeypatch):
635635
assert "AccessControl==7.3" not in cdeps
636636
# The override itself is carried by override-dependencies.
637637
assert list(doc["tool"]["uv"]["override-dependencies"]) == ["AccessControl==7.4"]
638+
639+
640+
def _write_mx_ini_packages(tmp_path, *names):
641+
lines = ["[settings]"]
642+
for name in names:
643+
lines.append(f"[{name}]")
644+
lines.append(f"url = https://example.com/{name}.git")
645+
lines.append("target = sources")
646+
lines.append("install-mode = editable")
647+
(tmp_path / "mx.ini").write_text("\n".join(lines) + "\n")
648+
649+
650+
def _run_hook(tmp_path):
651+
config = Configuration("mx.ini")
652+
state = State(config)
653+
UvPyprojectUpdater().write(state)
654+
return tomlkit.parse((tmp_path / "pyproject.toml").read_text())
655+
656+
657+
def test_drops_source_when_package_removed_from_mx_ini(tmp_path, monkeypatch):
658+
monkeypatch.chdir(tmp_path)
659+
(tmp_path / "pyproject.toml").write_text(
660+
'[project]\nname = "backend"\ndependencies = []\n\n[tool.uv]\nmanaged = true\n'
661+
)
662+
663+
# Phase 1: two packages -> both written to [tool.uv.sources]
664+
_write_mx_ini_packages(tmp_path, "addon-a", "addon-b")
665+
doc = _run_hook(tmp_path)
666+
assert "addon-a" in doc["tool"]["uv"]["sources"]
667+
assert "addon-b" in doc["tool"]["uv"]["sources"]
668+
669+
# Phase 2: addon-b removed from mx.ini -> must be removed from pyproject.toml
670+
_write_mx_ini_packages(tmp_path, "addon-a")
671+
doc = _run_hook(tmp_path)
672+
assert "addon-a" in doc["tool"]["uv"]["sources"]
673+
assert "addon-b" not in doc["tool"]["uv"]["sources"]
674+
675+
676+
def test_preserves_foreign_sources_when_reconciling(tmp_path, monkeypatch):
677+
monkeypatch.chdir(tmp_path)
678+
# A hand-written, non-mxdev source must never be touched.
679+
(tmp_path / "pyproject.toml").write_text(
680+
'[project]\nname = "backend"\ndependencies = []\n\n'
681+
"[tool.uv]\nmanaged = true\n\n"
682+
"[tool.uv.sources]\n"
683+
'my-fork = { git = "https://github.com/me/my-fork.git", branch = "main" }\n'
684+
)
685+
686+
_write_mx_ini_packages(tmp_path, "addon-a")
687+
doc = _run_hook(tmp_path)
688+
assert "addon-a" in doc["tool"]["uv"]["sources"]
689+
assert "my-fork" in doc["tool"]["uv"]["sources"]
690+
691+
# Drop addon-a: it goes away, the foreign source stays.
692+
(tmp_path / "mx.ini").write_text("[settings]\n")
693+
doc = _run_hook(tmp_path)
694+
assert "addon-a" not in doc["tool"]["uv"]["sources"]
695+
assert "my-fork" in doc["tool"]["uv"]["sources"]
696+
697+
698+
def test_skip_install_mode_removes_existing_source(tmp_path, monkeypatch):
699+
monkeypatch.chdir(tmp_path)
700+
(tmp_path / "pyproject.toml").write_text(
701+
'[project]\nname = "backend"\ndependencies = []\n\n[tool.uv]\nmanaged = true\n'
702+
)
703+
704+
_write_mx_ini_packages(tmp_path, "addon-a")
705+
doc = _run_hook(tmp_path)
706+
assert "addon-a" in doc["tool"]["uv"]["sources"]
707+
708+
# Switch addon-a to skip -> its source must be removed.
709+
(tmp_path / "mx.ini").write_text(
710+
"[settings]\n[addon-a]\nurl = https://example.com/addon-a.git\n" "target = sources\ninstall-mode = skip\n"
711+
)
712+
doc = _run_hook(tmp_path)
713+
assert "addon-a" not in doc["tool"]["uv"].get("sources", {})
714+
715+
716+
def test_source_reconcile_idempotency(tmp_path, monkeypatch):
717+
monkeypatch.chdir(tmp_path)
718+
(tmp_path / "pyproject.toml").write_text(
719+
'[project]\nname = "backend"\ndependencies = []\n\n[tool.uv]\nmanaged = true\n'
720+
)
721+
_write_mx_ini_packages(tmp_path, "addon-a", "addon-b")
722+
723+
UvPyprojectUpdater().write(State(Configuration("mx.ini")))
724+
first = (tmp_path / "pyproject.toml").read_text()
725+
UvPyprojectUpdater().write(State(Configuration("mx.ini")))
726+
second = (tmp_path / "pyproject.toml").read_text()
727+
assert first == second

0 commit comments

Comments
 (0)