Skip to content
Open
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
@@ -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]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't match the project's established changelog format. Should be:

Suggested change
[erral, 2026-03-27]
[erral]


## 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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing whitespace after the period.


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"]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mxdev's design principle is "Minimal dependencies: Only packaging at runtime." tomlkit should be an optional/extras dependency, not a hard one.

Suggested change
dependencies = ["packaging", "tomlkit>=0.12.0"]
dependencies = ["packaging"]

Add an extras group instead:

[project.optional-dependencies]
uv = ["tomlkit>=0.12.0"]

Then lazy-import in the hook with a clear install hint on ImportError.


[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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any is only used for doc param in _update_pyproject. Use tomlkit.TOMLDocument for actual type safety.


import logging
import re
import tomlkit
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Top-level import means the module fails to load if tomlkit is not installed. Move this into the methods that need it (lazy import) so the hook class can be loaded without the dependency. Raise a clear RuntimeError with install instructions (pip install mxdev[uv]) when the import fails at call time.



logger = logging.getLogger("mxdev")


def normalize_name(name: str) -> str:
"""PEP 503 normalization: lowercased, runs of -, _, . become single -"""
return re.sub(r"[-_.]+", "-", name).lower()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

packaging is already a dependency and provides packaging.utils.canonicalize_name() which does exactly this (PEP 503). No need to reimplement.

Suggested change
return re.sub(r"[-_.]+", "-", name).lower()
from packaging.utils import canonicalize_name as normalize_name



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

def write(self, state: State) -> None:
pyproject_path = Path("pyproject.toml")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Path("pyproject.toml") is CWD-relative. If mxdev is invoked with -c /other/path/mx.ini, this reads/writes the wrong file. Should resolve relative to the config file location or accept the path from state/config.

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)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catching bare Exception swallows programming errors (TypeError, AttributeError, KeyError). Narrow to OSError (covers permission, encoding, disk-full). Same applies to the write block below at line 59.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-atomic write. If the process is interrupted here (Ctrl+C, crash, disk full), pyproject.toml is truncated/corrupted — this is the user's project config file.

Write to a temp file + os.replace() instead:

import tempfile
with tempfile.NamedTemporaryFile(
    mode="w", dir=pyproject_path.parent,
    suffix=".tmp", delete=False, encoding="utf-8"
) as f:
    tomlkit.dump(doc, f)
    tmp = f.name
os.replace(tmp, str(pyproject_path))

def _update_pyproject(self, doc: Any, state: State) -> None:
"""Modify the pyproject.toml document based on mxdev state."""
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as line 37: narrow except Exception to except OSError.

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()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

direct is deprecated elsewhere in mxdev and gets normalized to editable during config parsing (see config.py:160-165). By the time the hook runs, no package should have install-mode == "direct". This branch is dead code — either remove it or add a comment explaining why it's here.

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()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This entire block (lines 108–126) should be removed.

Writing to [project.dependencies] mutates the project's public dependency metadata. Problems:

  • Entries survive after source dirs are removed — no cleanup mechanism
  • Easily committed to VCS by accident
  • Changes uv sync / pip install behavior permanently
  • Only grows, never shrinks (not idempotent)

[tool.uv.sources] is the correct place for dev-time path mappings. Users should manage [project.dependencies] themselves.


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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage:

  • subdirectory handling (code line 82–84)
  • Existing dependencies not duplicated
  • Error paths (unreadable/unwritable pyproject.toml)
  • Idempotency (hook run twice → same result)
  • Hook behavior when tomlkit is not installed (after making it optional)

Loading