Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 44 additions & 22 deletions src/mxdev/uv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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", "")
Expand All @@ -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:
Expand All @@ -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"]
90 changes: 90 additions & 0 deletions tests/test_uv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading