-
-
Notifications
You must be signed in to change notification settings - Fork 2
Edit pyproject.toml file's tool.uv.sources option if it is a uv-managed project
#81
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a4e0ab3
3f94e9b
ba6ee86
6e3d691
2670512
5ca157c
b5b0637
ca05f18
c2b3360
645f5f7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
|
@@ -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. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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` | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -21,7 +21,7 @@ classifiers = [ | |||||
| "Programming Language :: Python :: 3.13", | ||||||
| "Programming Language :: Python :: 3.14", | ||||||
| ] | ||||||
| dependencies = ["packaging"] | ||||||
| dependencies = ["packaging", "tomlkit>=0.12.0"] | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. mxdev's design principle is "Minimal dependencies: Only
Suggested change
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 |
||||||
|
|
||||||
| [project.optional-dependencies] | ||||||
| mypy = [] | ||||||
|
|
@@ -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" | ||||||
|
|
||||||
| 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 | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||
|
|
||||||
| import logging | ||||||
| import re | ||||||
| import tomlkit | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Top-level import means the module fails to load if |
||||||
|
|
||||||
|
|
||||||
| logger = logging.getLogger("mxdev") | ||||||
|
|
||||||
|
|
||||||
| def normalize_name(name: str) -> str: | ||||||
| """PEP 503 normalization: lowercased, runs of -, _, . become single -""" | ||||||
| return re.sub(r"[-_.]+", "-", name).lower() | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
|
||||||
|
|
||||||
| 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") | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||
| 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) | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Catching bare |
||||||
| 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) | ||||||
|
|
||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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), Write to a temp file + 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.""" | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same as line 37: narrow |
||||||
| 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() | ||||||
|
|
||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||
| 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() | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This entire block (lines 108–126) should be removed. Writing to
|
||||||
|
|
||||||
| 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) | ||||||
| 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing test coverage:
|
||
There was a problem hiding this comment.
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: