Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a4e0ab3
build: Add tomlkit as a core dependency for uv support
erral Mar 27, 2026
3f94e9b
feat: Merge UvPyprojectUpdater hook directly into mxdev core
erral Mar 27, 2026
ba6ee86
test: Add proper tests for UvPyprojectUpdater hook with managed=true …
erral Mar 27, 2026
6e3d691
docs: Document UV pyproject updater integration
erral Mar 27, 2026
2670512
fix: Update entrypoint key to 'hook' and attribute contributor in CHA…
erral Mar 27, 2026
5ca157c
test: Add specific test for managed=false skipping
erral Mar 27, 2026
b5b0637
docs: Move and expand UV Pyproject Integration documentation
erral Mar 27, 2026
ca05f18
docs: Remove duplicate UV Pyproject Integration section
erral Mar 27, 2026
c2b3360
test: Rework test_hook_skips_when_pyproject_toml_missing to use tmp_path
erral Mar 27, 2026
645f5f7
Update README.md
erral Mar 28, 2026
bed9e2c
build: Make tomlkit optional and update documentation per PR review
erral Apr 1, 2026
24c505b
refactor: Lazy load tomlkit, use atomic writes, and fix path resolution
erral Apr 1, 2026
0a07ad3
refactor: Remove project.dependencies mutation and clean up dead code
erral Apr 1, 2026
da10744
test: Add missing test coverage and remove obsolete tests
erral Apr 1, 2026
6dedfa7
move imports to the top of the file
erral Apr 1, 2026
415a594
move imports to the top of the file
erral Apr 1, 2026
24ef1bd
Use TYPE_CHECKING for tomlkit type hint
erral Apr 1, 2026
86f69c7
fix: Defer tomlkit import until uv management is confirmed
erral Apr 1, 2026
05df311
fix: Clean up temporary files on write failure
erral Apr 1, 2026
49bb647
test: Remove misleading relative path resolution test
erral Apr 1, 2026
2860b1b
fix: Defer tomlkit import using tomllib fallback strategy
erral Apr 1, 2026
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
@@ -1,4 +1,9 @@
## Changes

## 5.2.0 (unreleased)

- Feature: Built-in integration with `uv` through `pyproject.toml`. When `mxdev` is run, it checks if the project has a `pyproject.toml` containing `[tool.uv]` with `managed = true`. If so, mxdev automatically adds checked-out packages to `[tool.uv.sources]` and `[project.dependencies]`. This allows for seamless use of `uv sync` or `uv run` with local checkouts. `tomlkit` is now a core dependency to preserve `pyproject.toml` formatting during updates.
[erral, 2026-03-27]
Comment thread
erral marked this conversation as resolved.
Outdated

## 5.1.0

Expand Down
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,6 @@ If there is a source section defined for the same package, the source will be us
Note: When using [uv](https://pypi.org/project/uv/) pip install the version overrides here are not needed, since it [supports overrides natively](https://github.com/astral-sh/uv?tab=readme-ov-file#dependency-overrides).
With uv it is recommended to create an `overrides.txt` file with the version overrides and use `uv pip install --override overrides.txt [..]` to install the packages.


##### `ignores`

Ignore packages that are already defined in a dependent constraints file.
Expand Down Expand Up @@ -295,6 +294,28 @@ Mxdev will

Now, use the generated requirements and constraints files with i.e. `pip install -r requirements-mxdev.txt`.

## uv pyproject.toml integration

mxdev includes a built-in hook to automatically update your `pyproject.toml` file when working with [uv](https://docs.astral.sh/uv/)-managed projects.
Comment thread
erral marked this conversation as resolved.
Outdated

If your `pyproject.toml` contains the `[tool.uv]` table with `managed = true`:
```toml
[tool.uv]
managed = true
```

mxdev will automatically:
1. Inject the local VCS paths of your developed packages into `[tool.uv.sources]`.
2. Add the packages to `[project.dependencies]` if they are not already present.

This allows you to seamlessly use `uv sync` or `uv run` with the packages mxdev has checked out for you, without needing to use `requirements-mxdev.txt`.

To disable this feature, you can either remove the `managed = true` flag from your `pyproject.toml`, or explicitly set it to `false`:
```toml
[tool.uv]
managed = false
```

## Example Configuration

### Example `mx.ini`
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ classifiers = [
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
dependencies = ["packaging"]
dependencies = ["packaging", "tomlkit>=0.12.0"]
Comment thread
erral marked this conversation as resolved.
Outdated

[project.optional-dependencies]
mypy = []
Expand All @@ -41,6 +41,9 @@ Source = "https://github.com/mxstack/mxdev/"
[project.scripts]
mxdev = "mxdev.main:main"

[project.entry-points.mxdev]
hook = "mxdev.uv:UvPyprojectUpdater"

[project.entry-points."mxdev.workingcopytypes"]
svn = "mxdev.vcs.svn:SVNWorkingCopy"
git = "mxdev.vcs.git:GitWorkingCopy"
Expand Down
126 changes: 126 additions & 0 deletions src/mxdev/uv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
from mxdev.hooks import Hook
from mxdev.state import State
from pathlib import Path
from typing import Any
Comment thread
erral marked this conversation as resolved.
Outdated

import logging
import re
import tomlkit
Comment thread
erral marked this conversation as resolved.
Outdated


logger = logging.getLogger("mxdev")


def normalize_name(name: str) -> str:
"""PEP 503 normalization: lowercased, runs of -, _, . become single -"""
return re.sub(r"[-_.]+", "-", name).lower()
Comment thread
erral marked this conversation as resolved.
Outdated


class UvPyprojectUpdater(Hook):
"""An mxdev hook that updates pyproject.toml during the write phase for uv-managed projects."""

namespace = "uv"

def read(self, state: State) -> None:
pass

Comment thread
erral marked this conversation as resolved.
def write(self, state: State) -> None:
pyproject_path = Path("pyproject.toml")
Comment thread
erral marked this conversation as resolved.
Outdated
if not pyproject_path.exists():
logger.debug("[%s] pyproject.toml not found, skipping.", self.namespace)
return

try:
with pyproject_path.open("r", encoding="utf-8") as f:
doc = tomlkit.load(f)
except Exception as e:
logger.error("[%s] Failed to read pyproject.toml: %s", self.namespace, e)
Comment thread
erral marked this conversation as resolved.
return

# Check for the UV managed signal
tool_uv = doc.get("tool", {}).get("uv", {})
if tool_uv.get("managed") is not True:
logger.debug(
"[%s] Project not explicitly managed by uv ([tool.uv] managed=true missing), skipping.", self.namespace
)
return

logger.info("[%s] Updating pyproject.toml...", self.namespace)
self._update_pyproject(doc, state)

try:
with pyproject_path.open("w", encoding="utf-8") as f:
tomlkit.dump(doc, f)
logger.info("[%s] Successfully updated pyproject.toml", self.namespace)
except Exception as e:
logger.error("[%s] Failed to write pyproject.toml: %s", self.namespace, e)

Comment thread
erral marked this conversation as resolved.
def _update_pyproject(self, doc: Any, state: State) -> None:
"""Modify the pyproject.toml document based on mxdev state."""
Comment thread
erral marked this conversation as resolved.
if not state.configuration.packages:
return

# 1. Update [tool.uv.sources]
if "tool" not in doc:
doc.add("tool", tomlkit.table())
if "uv" not in doc["tool"]:
doc["tool"]["uv"] = tomlkit.table()
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 state.configuration.packages.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", "")
if subdirectory:
package_path = package_path / subdirectory

try:
if package_path.is_absolute():
rel_path = package_path.relative_to(Path.cwd()).as_posix()
else:
rel_path = package_path.as_posix()
except ValueError:
rel_path = package_path.as_posix()

Comment thread
erral marked this conversation as resolved.
source_table = tomlkit.inline_table()
source_table.append("path", rel_path)

if install_mode in ("editable", "direct"):
source_table.append("editable", True)
elif install_mode == "fixed":
source_table.append("editable", False)

uv_sources[pkg_name] = source_table

# 2. Add packages to project.dependencies if not present
if "project" not in doc:
doc.add("project", tomlkit.table())

if "dependencies" not in doc["project"]:
doc["project"]["dependencies"] = tomlkit.array()
Comment thread
erral marked this conversation as resolved.
Outdated

dependencies = doc["project"]["dependencies"]
pkg_name_pattern = re.compile(r"^([a-zA-Z0-9_\-\.]+)")
existing_pkg_names = set()

for dep in dependencies:
match = pkg_name_pattern.match(str(dep).strip())
if match:
existing_pkg_names.add(normalize_name(match.group(1)))

for pkg_name, pkg_data in state.configuration.packages.items():
install_mode = pkg_data.get("install-mode", "editable")
if install_mode == "skip":
continue

normalized_name = normalize_name(pkg_name)
if normalized_name not in existing_pkg_names:
dependencies.append(pkg_name)
159 changes: 159 additions & 0 deletions tests/test_uv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
from mxdev.config import Configuration
from mxdev.state import State
from mxdev.uv import UvPyprojectUpdater

import tomlkit


def test_hook_skips_when_pyproject_toml_missing(mocker, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
hook = UvPyprojectUpdater()
(tmp_path / "mx.ini").write_text("[settings]")
config = Configuration("mx.ini")
state = State(config)
mock_logger = mocker.patch("mxdev.uv.logger")
hook.write(state)
mock_logger.debug.assert_called_with("[%s] pyproject.toml not found, skipping.", "uv")


def test_hook_skips_when_uv_managed_is_false_or_missing(mocker, tmp_path, monkeypatch):
# Test skipping logic when [tool.uv] is missing or managed != true
monkeypatch.chdir(tmp_path)
hook = UvPyprojectUpdater()
(tmp_path / "mx.ini").write_text("[settings]")
config = Configuration("mx.ini")
state = State(config)

# Mock pyproject.toml without tool.uv.managed
doc = tomlkit.document()
doc.add("project", tomlkit.table())
(tmp_path / "pyproject.toml").write_text(tomlkit.dumps(doc))

mock_logger = mocker.patch("mxdev.uv.logger")

# Store initial content
initial_content = (tmp_path / "pyproject.toml").read_text()

hook.write(state)
mock_logger.debug.assert_called_with(
"[%s] Project not explicitly managed by uv ([tool.uv] managed=true missing), skipping.", "uv"
)

# Verify the file was not modified
assert (tmp_path / "pyproject.toml").read_text() == initial_content


def test_hook_skips_when_uv_managed_is_false(mocker, tmp_path, monkeypatch):
# Test skipping logic when [tool.uv] managed is explicitly false
monkeypatch.chdir(tmp_path)
hook = UvPyprojectUpdater()
(tmp_path / "mx.ini").write_text("[settings]")
config = Configuration("mx.ini")
state = State(config)

# Mock pyproject.toml with tool.uv.managed = false
initial_toml = """
[tool.uv]
managed = false
"""
(tmp_path / "pyproject.toml").write_text(initial_toml.strip())

mock_logger = mocker.patch("mxdev.uv.logger")

# Store initial content
initial_content = (tmp_path / "pyproject.toml").read_text()

hook.write(state)
mock_logger.debug.assert_called_with(
"[%s] Project not explicitly managed by uv ([tool.uv] managed=true missing), skipping.", "uv"
)

# Verify the file was not modified
assert (tmp_path / "pyproject.toml").read_text() == initial_content


def test_hook_executes_when_uv_managed_is_true(mocker, tmp_path, monkeypatch):
# Test that updates proceed when managed = true is present
monkeypatch.chdir(tmp_path)
hook = UvPyprojectUpdater()

mx_ini = """
[settings]
[pkg1]
url = https://example.com/pkg1.git
target = sources
install-mode = editable
"""
(tmp_path / "mx.ini").write_text(mx_ini.strip())
config = Configuration("mx.ini")
state = State(config)

# Mock pyproject.toml with tool.uv.managed = true
initial_toml = """
[project]
name = "test"
dependencies = []

[tool.uv]
managed = true
"""
(tmp_path / "pyproject.toml").write_text(initial_toml.strip())

mock_logger = mocker.patch("mxdev.uv.logger")
hook.write(state)
mock_logger.info.assert_any_call("[%s] Updating pyproject.toml...", "uv")
mock_logger.info.assert_any_call("[%s] Successfully updated pyproject.toml", "uv")

# Verify the file was actually written correctly
doc = tomlkit.parse((tmp_path / "pyproject.toml").read_text())
assert "tool" in doc
assert "uv" in doc["tool"]
assert "sources" in doc["tool"]["uv"]
assert "pkg1" in doc["tool"]["uv"]["sources"]
assert doc["tool"]["uv"]["sources"]["pkg1"]["path"] == "sources/pkg1"
assert doc["tool"]["uv"]["sources"]["pkg1"]["editable"] is True
assert "pkg1" in doc["project"]["dependencies"]


def test_update_pyproject_respects_install_modes(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
hook = UvPyprojectUpdater()

mx_ini = """
[settings]
[editable-pkg]
url = https://example.com/e.git
target = sources
install-mode = editable

[fixed-pkg]
url = https://example.com/f.git
target = sources
install-mode = fixed

[skip-pkg]
url = https://example.com/s.git
target = sources
install-mode = skip
"""
(tmp_path / "mx.ini").write_text(mx_ini.strip())
config = Configuration("mx.ini")
state = State(config)

initial_toml = """
[project]
name = "test"
dependencies = []

[tool.uv]
managed = true
"""
(tmp_path / "pyproject.toml").write_text(initial_toml.strip())

hook.write(state)

doc = tomlkit.parse((tmp_path / "pyproject.toml").read_text())
sources = doc["tool"]["uv"]["sources"]
assert sources["editable-pkg"]["editable"] is True
assert sources["fixed-pkg"]["editable"] is False
assert "skip-pkg" not in sources
Comment thread
erral marked this conversation as resolved.
Loading