Skip to content

Commit b8c986e

Browse files
authored
Merge pull request #81 from erral/uv-managed
Edit pyproject.toml file's `tool.uv.sources` option if it is a uv-managed project
2 parents 5ca7f2d + 2860b1b commit b8c986e

File tree

5 files changed

+519
-1
lines changed

5 files changed

+519
-1
lines changed

CHANGES.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
## Changes
2+
3+
## 5.2.0 (unreleased)
4+
5+
- 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]`. This allows for seamless use of `uv sync` or `uv run` with local checkouts. `tomlkit` is now an optional dependency (install with `mxdev[uv]`) to preserve `pyproject.toml` formatting during updates.
6+
[erral]
27

38
## 5.1.0
49

README.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,6 @@ If there is a source section defined for the same package, the source will be us
155155
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).
156156
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.
157157

158-
159158
##### `ignores`
160159

161160
Ignore packages that are already defined in a dependent constraints file.
@@ -295,6 +294,32 @@ Mxdev will
295294

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

297+
## uv pyproject.toml integration
298+
299+
mxdev includes a built-in hook to automatically update your `pyproject.toml` file when working with [uv](https://docs.astral.sh/uv/)-managed projects.
300+
301+
To use this feature, you must install mxdev with the `uv` extra:
302+
303+
```bash
304+
pip install mxdev[uv]
305+
```
306+
307+
If your `pyproject.toml` contains the `[tool.uv]` table with `managed = true`:
308+
```toml
309+
[tool.uv]
310+
managed = true
311+
```
312+
313+
mxdev will automatically inject the local VCS paths of your developed packages into `[tool.uv.sources]`.
314+
315+
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`.
316+
317+
To disable this feature, you can either remove the `managed = true` flag from your `pyproject.toml`, or explicitly set it to `false`:
318+
```toml
319+
[tool.uv]
320+
managed = false
321+
```
322+
298323
## Example Configuration
299324

300325
### Example `mx.ini`

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,15 @@ classifiers = [
2424
dependencies = ["packaging"]
2525

2626
[project.optional-dependencies]
27+
uv = ["tomlkit>=0.12.0"]
2728
mypy = []
2829
test = [
2930
"pytest",
3031
"pytest-cov",
3132
"pytest-mock",
3233
"httpretty",
3334
"coverage[toml]",
35+
"tomlkit>=0.12.0",
3436
]
3537

3638
[project.urls]
@@ -41,6 +43,9 @@ Source = "https://github.com/mxstack/mxdev/"
4143
[project.scripts]
4244
mxdev = "mxdev.main:main"
4345

46+
[project.entry-points.mxdev]
47+
hook = "mxdev.uv:UvPyprojectUpdater"
48+
4449
[project.entry-points."mxdev.workingcopytypes"]
4550
svn = "mxdev.vcs.svn:SVNWorkingCopy"
4651
git = "mxdev.vcs.git:GitWorkingCopy"

src/mxdev/uv.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
from mxdev.hooks import Hook
2+
from mxdev.state import State
3+
from pathlib import Path
4+
from typing import TYPE_CHECKING
5+
6+
import logging
7+
import os
8+
import tempfile
9+
10+
11+
if TYPE_CHECKING:
12+
import tomlkit
13+
14+
15+
logger = logging.getLogger("mxdev")
16+
17+
18+
class UvPyprojectUpdater(Hook):
19+
"""An mxdev hook that updates pyproject.toml during the write phase for uv-managed projects."""
20+
21+
namespace = "uv"
22+
23+
def read(self, state: State) -> None:
24+
pass
25+
26+
def write(self, state: State) -> None:
27+
pyproject_path = Path(state.configuration.settings.get("directory", ".")) / "pyproject.toml"
28+
if not pyproject_path.exists():
29+
logger.debug("[%s] pyproject.toml not found, skipping.", self.namespace)
30+
return
31+
32+
try:
33+
content = pyproject_path.read_text(encoding="utf-8")
34+
except OSError as e:
35+
logger.error("[%s] Failed to read pyproject.toml: %s", self.namespace, e)
36+
return
37+
38+
# Attempt to parse using standard library (Python 3.11+)
39+
try:
40+
import tomllib
41+
42+
parsed = tomllib.loads(content)
43+
if parsed.get("tool", {}).get("uv", {}).get("managed") is not True:
44+
logger.debug(
45+
"[%s] Project not explicitly managed by uv ([tool.uv] managed=true missing), skipping.",
46+
self.namespace,
47+
)
48+
return
49+
except ImportError:
50+
# Fallback for Python 3.10: fast string check to avoid tomlkit overhead
51+
if "[tool.uv]" not in content:
52+
logger.debug(
53+
"[%s] Project not explicitly managed by uv ([tool.uv] managed=true missing), skipping.",
54+
self.namespace,
55+
)
56+
return
57+
except Exception:
58+
# If the parser fails (e.g., malformed TOML), just skip.
59+
return
60+
61+
# Now we are confident it's a uv project, require our heavy dependency
62+
try:
63+
from typing import TYPE_CHECKING
64+
65+
if not TYPE_CHECKING:
66+
import tomlkit
67+
except ImportError:
68+
raise RuntimeError("tomlkit is required for the uv hook. Install it with: pip install mxdev[uv]")
69+
70+
doc = tomlkit.loads(content)
71+
72+
# Check for the UV managed signal
73+
tool_uv = doc.get("tool", {}).get("uv", {})
74+
if tool_uv.get("managed") is not True:
75+
logger.debug(
76+
"[%s] Project not explicitly managed by uv ([tool.uv] managed=true missing), skipping.", self.namespace
77+
)
78+
return
79+
80+
logger.info("[%s] Updating pyproject.toml...", self.namespace)
81+
self._update_pyproject(doc, state)
82+
83+
tmp = None
84+
try:
85+
with tempfile.NamedTemporaryFile(
86+
mode="w", dir=pyproject_path.parent, suffix=".tmp", delete=False, encoding="utf-8"
87+
) as f:
88+
tomlkit.dump(doc, f)
89+
tmp = f.name
90+
os.replace(tmp, str(pyproject_path))
91+
tmp = None # success, don't clean up
92+
logger.info("[%s] Successfully updated pyproject.toml", self.namespace)
93+
except OSError as e:
94+
logger.error("[%s] Failed to write pyproject.toml: %s", self.namespace, e)
95+
finally:
96+
if tmp and os.path.exists(tmp):
97+
os.unlink(tmp)
98+
99+
def _update_pyproject(self, doc: "tomlkit.TOMLDocument", state: State) -> None:
100+
"""Modify the pyproject.toml document based on mxdev state."""
101+
import tomlkit
102+
103+
if not state.configuration.packages:
104+
return
105+
106+
# 1. Update [tool.uv.sources]
107+
if "tool" not in doc:
108+
doc.add("tool", tomlkit.table())
109+
if "uv" not in doc["tool"]:
110+
doc["tool"]["uv"] = tomlkit.table()
111+
if "sources" not in doc["tool"]["uv"]:
112+
doc["tool"]["uv"]["sources"] = tomlkit.table()
113+
114+
uv_sources = doc["tool"]["uv"]["sources"]
115+
116+
for pkg_name, pkg_data in state.configuration.packages.items():
117+
install_mode = pkg_data.get("install-mode", "editable")
118+
119+
if install_mode == "skip":
120+
continue
121+
122+
target_dir = Path(pkg_data.get("target", "sources"))
123+
package_path = target_dir / pkg_name
124+
subdirectory = pkg_data.get("subdirectory", "")
125+
if subdirectory:
126+
package_path = package_path / subdirectory
127+
128+
try:
129+
if package_path.is_absolute():
130+
rel_path = package_path.relative_to(Path.cwd()).as_posix()
131+
else:
132+
rel_path = package_path.as_posix()
133+
except ValueError:
134+
rel_path = package_path.as_posix()
135+
136+
source_table = tomlkit.inline_table()
137+
source_table.append("path", rel_path)
138+
139+
if install_mode == "editable":
140+
source_table.append("editable", True)
141+
elif install_mode == "fixed":
142+
source_table.append("editable", False)
143+
144+
uv_sources[pkg_name] = source_table

0 commit comments

Comments
 (0)