From a4e0ab3bf04587840020ea2fc37428549ac0e56b Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 27 Mar 2026 16:01:54 +0100 Subject: [PATCH 01/21] build: Add tomlkit as a core dependency for uv support --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6733e2d..c077624 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ] -dependencies = ["packaging"] +dependencies = ["packaging", "tomlkit>=0.12.0"] [project.optional-dependencies] mypy = [] From 3f94e9b8d5d20186ed3c11be24420c047a4a376f Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 27 Mar 2026 16:03:51 +0100 Subject: [PATCH 02/21] feat: Merge UvPyprojectUpdater hook directly into mxdev core --- pyproject.toml | 3 ++ src/mxdev/uv.py | 127 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 src/mxdev/uv.py diff --git a/pyproject.toml b/pyproject.toml index c077624..89a6224 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,9 @@ Source = "https://github.com/mxstack/mxdev/" [project.scripts] mxdev = "mxdev.main:main" +[project.entry-points.mxdev] +uv = "mxdev.uv:UvPyprojectUpdater" + [project.entry-points."mxdev.workingcopytypes"] svn = "mxdev.vcs.svn:SVNWorkingCopy" git = "mxdev.vcs.git:GitWorkingCopy" diff --git a/src/mxdev/uv.py b/src/mxdev/uv.py new file mode 100644 index 0000000..c8efe68 --- /dev/null +++ b/src/mxdev/uv.py @@ -0,0 +1,127 @@ +import logging +import re +from pathlib import Path +from typing import Any + +import tomlkit +from mxdev.hooks import Hook +from mxdev.state import State + +logger = logging.getLogger("mxdev") + + +def normalize_name(name: str) -> str: + """PEP 503 normalization: lowercased, runs of -, _, . become single -""" + return re.sub(r"[-_.]+", "-", name).lower() + + +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") + 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) + 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) + + def _update_pyproject(self, doc: Any, state: State) -> None: + """Modify the pyproject.toml document based on mxdev state.""" + 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() + + 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() + + 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) From ba6ee86126b93e8562db8887224c0377b0a4d7b3 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 27 Mar 2026 16:04:59 +0100 Subject: [PATCH 03/21] test: Add proper tests for UvPyprojectUpdater hook with managed=true condition --- tests/test_uv.py | 114 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 tests/test_uv.py diff --git a/tests/test_uv.py b/tests/test_uv.py new file mode 100644 index 0000000..e6cdeba --- /dev/null +++ b/tests/test_uv.py @@ -0,0 +1,114 @@ +from pathlib import Path + +import pytest +import tomlkit + +from mxdev.state import State +from mxdev.uv import UvPyprojectUpdater + + +class MockConfig: + def __init__(self, packages=None, settings=None): + self.packages = packages or {} + self.settings = settings or {} + + +def test_hook_skips_when_pyproject_toml_missing(mocker): + hook = UvPyprojectUpdater() + state = State(MockConfig()) + mocker.patch("mxdev.uv.Path.exists", return_value=False) + 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): + # Test skipping logic when [tool.uv] is missing or managed != true + hook = UvPyprojectUpdater() + state = State(MockConfig()) + + # Mock pyproject.toml without tool.uv.managed + doc = tomlkit.document() + doc.add("project", tomlkit.table()) + + mocker.patch("mxdev.uv.Path.exists", return_value=True) + mocker.patch("mxdev.uv.Path.open", mocker.mock_open(read_data=tomlkit.dumps(doc))) + mock_logger = mocker.patch("mxdev.uv.logger") + + hook.write(state) + mock_logger.debug.assert_called_with( + "[%s] Project not explicitly managed by uv ([tool.uv] managed=true missing), skipping.", "uv" + ) + + +def test_hook_executes_when_uv_managed_is_true(mocker, tmp_path): + # Test that updates proceed when managed = true is present + hook = UvPyprojectUpdater() + + packages = {"pkg1": {"target": "sources", "install-mode": "editable"}} + state = State(MockConfig(packages=packages)) + + # Mock pyproject.toml with tool.uv.managed = true + initial_toml = """ +[tool.uv] +managed = true +""" + doc = tomlkit.parse(initial_toml) + + mocker.patch("mxdev.uv.Path.exists", return_value=True) + + # We need a proper mock for pathlib.Path.open that returns our doc and captures the write + mock_file = mocker.mock_open(read_data=initial_toml) + mocker.patch("mxdev.uv.Path.open", mock_file) + + 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") + + +# Additional test cases to migrate from the old tests +def test_update_pyproject_creates_tool_uv_sources(): + hook = UvPyprojectUpdater() + doc = tomlkit.document() + packages = {"pkg1": {"target": "sources", "install-mode": "editable"}} + state = State(MockConfig(packages=packages)) + + hook._update_pyproject(doc, state) + + assert "tool" in doc + assert "uv" in doc["tool"] + assert "sources" in doc["tool"]["uv"] + sources = doc["tool"]["uv"]["sources"] + assert "pkg1" in sources + assert sources["pkg1"]["path"] == "sources/pkg1" + assert sources["pkg1"]["editable"] is True + + +def test_update_pyproject_respects_install_modes(): + hook = UvPyprojectUpdater() + doc = tomlkit.document() + packages = { + "editable-pkg": {"target": "sources", "install-mode": "editable"}, + "fixed-pkg": {"target": "sources", "install-mode": "fixed"}, + "skip-pkg": {"target": "sources", "install-mode": "skip"}, + } + state = State(MockConfig(packages=packages)) + + hook._update_pyproject(doc, state) + 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 + + +def test_update_pyproject_adds_dependencies(): + hook = UvPyprojectUpdater() + doc = tomlkit.document() + packages = {"pkg1": {"target": "sources", "install-mode": "editable"}} + state = State(MockConfig(packages=packages)) + + hook._update_pyproject(doc, state) + deps = doc["project"]["dependencies"] + assert "pkg1" in deps From 6e3d691bbd7a7485f63d35db60a2fd25462c2bfb Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 27 Mar 2026 16:05:38 +0100 Subject: [PATCH 04/21] docs: Document UV pyproject updater integration --- CHANGES.md | 4 ++++ README.md | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 3ce6057..c8354f1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,4 +1,8 @@ ## 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]`, making the external `mxdev-uv-pyproject-updater` plugin obsolete. 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. ## 5.1.0 diff --git a/README.md b/README.md index 21ae6a3..90a16ec 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,22 @@ 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. +#### UV Pyproject 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. + +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`. + ##### `ignores` From 2670512c97b5507b3e4a2ff4e4aa9fc1a6dad168 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 27 Mar 2026 16:07:59 +0100 Subject: [PATCH 05/21] fix: Update entrypoint key to 'hook' and attribute contributor in CHANGES --- CHANGES.md | 3 ++- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index c8354f1..d909927 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,7 +2,8 @@ ## 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]`, making the external `mxdev-uv-pyproject-updater` plugin obsolete. 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. +- 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] ## 5.1.0 diff --git a/pyproject.toml b/pyproject.toml index 89a6224..58fc6fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ Source = "https://github.com/mxstack/mxdev/" mxdev = "mxdev.main:main" [project.entry-points.mxdev] -uv = "mxdev.uv:UvPyprojectUpdater" +hook = "mxdev.uv:UvPyprojectUpdater" [project.entry-points."mxdev.workingcopytypes"] svn = "mxdev.vcs.svn:SVNWorkingCopy" From 5ca157c38ce3f858b9b43cea21fc97fd052cf8bc Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 27 Mar 2026 16:10:41 +0100 Subject: [PATCH 06/21] test: Add specific test for managed=false skipping --- tests/test_uv.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_uv.py b/tests/test_uv.py index e6cdeba..60a9c99 100644 --- a/tests/test_uv.py +++ b/tests/test_uv.py @@ -41,6 +41,28 @@ def test_hook_skips_when_uv_managed_is_false_or_missing(mocker, tmp_path): ) +def test_hook_skips_when_uv_managed_is_false(mocker, tmp_path): + # Test skipping logic when [tool.uv] managed is explicitly false + hook = UvPyprojectUpdater() + state = State(MockConfig()) + + # Mock pyproject.toml with tool.uv.managed = false + initial_toml = """ +[tool.uv] +managed = false +""" + doc = tomlkit.parse(initial_toml) + + mocker.patch("mxdev.uv.Path.exists", return_value=True) + mocker.patch("mxdev.uv.Path.open", mocker.mock_open(read_data=initial_toml)) + mock_logger = mocker.patch("mxdev.uv.logger") + + hook.write(state) + mock_logger.debug.assert_called_with( + "[%s] Project not explicitly managed by uv ([tool.uv] managed=true missing), skipping.", "uv" + ) + + def test_hook_executes_when_uv_managed_is_true(mocker, tmp_path): # Test that updates proceed when managed = true is present hook = UvPyprojectUpdater() From b5b0637697ea769b825374c7238f88b449904579 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 27 Mar 2026 16:12:36 +0100 Subject: [PATCH 07/21] docs: Move and expand UV Pyproject Integration documentation --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index 90a16ec..dfe8387 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,28 @@ Mxdev will Now, use the generated requirements and constraints files with i.e. `pip install -r requirements-mxdev.txt`. +## UV Pyproject 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. + +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` From ca05f18253c6db9d813beb0c9ff002720dd87964 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 27 Mar 2026 16:14:43 +0100 Subject: [PATCH 08/21] docs: Remove duplicate UV Pyproject Integration section --- README.md | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/README.md b/README.md index dfe8387..670b3f3 100644 --- a/README.md +++ b/README.md @@ -155,23 +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. -#### UV Pyproject 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. - -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`. - - ##### `ignores` Ignore packages that are already defined in a dependent constraints file. From c2b336067c77569e3586ab5a3c74c114d3e4c12d Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 27 Mar 2026 16:37:24 +0100 Subject: [PATCH 09/21] test: Rework test_hook_skips_when_pyproject_toml_missing to use tmp_path --- src/mxdev/uv.py | 13 ++-- tests/test_uv.py | 159 +++++++++++++++++++++++++++-------------------- 2 files changed, 97 insertions(+), 75 deletions(-) diff --git a/src/mxdev/uv.py b/src/mxdev/uv.py index c8efe68..71ed1f9 100644 --- a/src/mxdev/uv.py +++ b/src/mxdev/uv.py @@ -1,11 +1,12 @@ -import logging -import re +from mxdev.hooks import Hook +from mxdev.state import State from pathlib import Path from typing import Any +import logging +import re import tomlkit -from mxdev.hooks import Hook -from mxdev.state import State + logger = logging.getLogger("mxdev") @@ -16,9 +17,7 @@ def normalize_name(name: str) -> str: class UvPyprojectUpdater(Hook): - """ - An mxdev hook that updates pyproject.toml during the write phase for uv-managed projects. - """ + """An mxdev hook that updates pyproject.toml during the write phase for uv-managed projects.""" namespace = "uv" diff --git a/tests/test_uv.py b/tests/test_uv.py index 60a9c99..507c0ff 100644 --- a/tests/test_uv.py +++ b/tests/test_uv.py @@ -1,136 +1,159 @@ -from pathlib import Path - -import pytest -import tomlkit - +from mxdev.config import Configuration from mxdev.state import State from mxdev.uv import UvPyprojectUpdater - -class MockConfig: - def __init__(self, packages=None, settings=None): - self.packages = packages or {} - self.settings = settings or {} +import tomlkit -def test_hook_skips_when_pyproject_toml_missing(mocker): +def test_hook_skips_when_pyproject_toml_missing(mocker, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) hook = UvPyprojectUpdater() - state = State(MockConfig()) - mocker.patch("mxdev.uv.Path.exists", return_value=False) + (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): +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() - state = State(MockConfig()) + (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)) - mocker.patch("mxdev.uv.Path.exists", return_value=True) - mocker.patch("mxdev.uv.Path.open", mocker.mock_open(read_data=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): +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() - state = State(MockConfig()) + (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 """ - doc = tomlkit.parse(initial_toml) + (tmp_path / "pyproject.toml").write_text(initial_toml.strip()) - mocker.patch("mxdev.uv.Path.exists", return_value=True) - mocker.patch("mxdev.uv.Path.open", mocker.mock_open(read_data=initial_toml)) 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): + +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() - packages = {"pkg1": {"target": "sources", "install-mode": "editable"}} - state = State(MockConfig(packages=packages)) + 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 """ - doc = tomlkit.parse(initial_toml) - - mocker.patch("mxdev.uv.Path.exists", return_value=True) - - # We need a proper mock for pathlib.Path.open that returns our doc and captures the write - mock_file = mocker.mock_open(read_data=initial_toml) - mocker.patch("mxdev.uv.Path.open", mock_file) + (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") - -# Additional test cases to migrate from the old tests -def test_update_pyproject_creates_tool_uv_sources(): - hook = UvPyprojectUpdater() - doc = tomlkit.document() - packages = {"pkg1": {"target": "sources", "install-mode": "editable"}} - state = State(MockConfig(packages=packages)) - - hook._update_pyproject(doc, state) - + # 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"] - sources = doc["tool"]["uv"]["sources"] - assert "pkg1" in sources - assert sources["pkg1"]["path"] == "sources/pkg1" - assert sources["pkg1"]["editable"] is True + 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(): +def test_update_pyproject_respects_install_modes(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) hook = UvPyprojectUpdater() - doc = tomlkit.document() - packages = { - "editable-pkg": {"target": "sources", "install-mode": "editable"}, - "fixed-pkg": {"target": "sources", "install-mode": "fixed"}, - "skip-pkg": {"target": "sources", "install-mode": "skip"}, - } - state = State(MockConfig(packages=packages)) - - hook._update_pyproject(doc, state) + + 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 - - -def test_update_pyproject_adds_dependencies(): - hook = UvPyprojectUpdater() - doc = tomlkit.document() - packages = {"pkg1": {"target": "sources", "install-mode": "editable"}} - state = State(MockConfig(packages=packages)) - - hook._update_pyproject(doc, state) - deps = doc["project"]["dependencies"] - assert "pkg1" in deps From 645f5f7f96862964c12192d42f48e36ff1653c64 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Sat, 28 Mar 2026 08:13:27 +0100 Subject: [PATCH 10/21] Update README.md Co-authored-by: Steve Piercy --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 670b3f3..435e146 100644 --- a/README.md +++ b/README.md @@ -294,7 +294,7 @@ Mxdev will Now, use the generated requirements and constraints files with i.e. `pip install -r requirements-mxdev.txt`. -## UV Pyproject Integration +## 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. From bed9e2caa8e0ab0f75ba02084e8d33a48c086cfe Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Wed, 1 Apr 2026 08:37:36 +0200 Subject: [PATCH 11/21] build: Make tomlkit optional and update documentation per PR review --- CHANGES.md | 4 ++-- README.md | 10 +++++++--- pyproject.toml | 4 +++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index d909927..0fd20d3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,8 +2,8 @@ ## 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] +- 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. + [erral] ## 5.1.0 diff --git a/README.md b/README.md index 435e146..c691d1a 100644 --- a/README.md +++ b/README.md @@ -298,15 +298,19 @@ Now, use the generated requirements and constraints files with i.e. `pip install mxdev includes a built-in hook to automatically update your `pyproject.toml` file when working with [uv](https://docs.astral.sh/uv/)-managed projects. +To use this feature, you must install mxdev with the `uv` extra: + +```bash +pip install mxdev[uv] +``` + 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. +mxdev will automatically inject the local VCS paths of your developed packages into `[tool.uv.sources]`. 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`. diff --git a/pyproject.toml b/pyproject.toml index 58fc6fb..fe21330 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,9 +21,10 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ] -dependencies = ["packaging", "tomlkit>=0.12.0"] +dependencies = ["packaging"] [project.optional-dependencies] +uv = ["tomlkit>=0.12.0"] mypy = [] test = [ "pytest", @@ -31,6 +32,7 @@ test = [ "pytest-mock", "httpretty", "coverage[toml]", + "tomlkit>=0.12.0", ] [project.urls] From 24c505b1d7f1938c91bc2344f2342c773afd56f3 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Wed, 1 Apr 2026 08:41:27 +0200 Subject: [PATCH 12/21] refactor: Lazy load tomlkit, use atomic writes, and fix path resolution --- src/mxdev/uv.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/mxdev/uv.py b/src/mxdev/uv.py index 71ed1f9..443f685 100644 --- a/src/mxdev/uv.py +++ b/src/mxdev/uv.py @@ -5,7 +5,6 @@ import logging import re -import tomlkit logger = logging.getLogger("mxdev") @@ -25,7 +24,12 @@ def read(self, state: State) -> None: pass def write(self, state: State) -> None: - pyproject_path = Path("pyproject.toml") + try: + import tomlkit + except ImportError: + raise RuntimeError("tomlkit is required for the uv hook. Install it with: pip install mxdev[uv]") + + pyproject_path = Path(state.configuration.settings.get("directory", ".")) / "pyproject.toml" if not pyproject_path.exists(): logger.debug("[%s] pyproject.toml not found, skipping.", self.namespace) return @@ -33,7 +37,7 @@ def write(self, state: State) -> None: try: with pyproject_path.open("r", encoding="utf-8") as f: doc = tomlkit.load(f) - except Exception as e: + except OSError as e: logger.error("[%s] Failed to read pyproject.toml: %s", self.namespace, e) return @@ -49,14 +53,23 @@ def write(self, state: State) -> None: self._update_pyproject(doc, state) try: - with pyproject_path.open("w", encoding="utf-8") as f: + import os + 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)) logger.info("[%s] Successfully updated pyproject.toml", self.namespace) - except Exception as e: + except OSError as e: logger.error("[%s] Failed to write pyproject.toml: %s", self.namespace, e) def _update_pyproject(self, doc: Any, state: State) -> None: """Modify the pyproject.toml document based on mxdev state.""" + import tomlkit + if not state.configuration.packages: return From 0a07ad3d1243b5a05022103354e367177d0df80b Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Wed, 1 Apr 2026 08:45:00 +0200 Subject: [PATCH 13/21] refactor: Remove project.dependencies mutation and clean up dead code --- src/mxdev/uv.py | 34 ++-------------------------------- 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/src/mxdev/uv.py b/src/mxdev/uv.py index 443f685..6363919 100644 --- a/src/mxdev/uv.py +++ b/src/mxdev/uv.py @@ -10,11 +10,6 @@ logger = logging.getLogger("mxdev") -def normalize_name(name: str) -> str: - """PEP 503 normalization: lowercased, runs of -, _, . become single -""" - return re.sub(r"[-_.]+", "-", name).lower() - - class UvPyprojectUpdater(Hook): """An mxdev hook that updates pyproject.toml during the write phase for uv-managed projects.""" @@ -66,7 +61,7 @@ def write(self, state: State) -> None: except OSError as e: logger.error("[%s] Failed to write pyproject.toml: %s", self.namespace, e) - def _update_pyproject(self, doc: Any, state: State) -> None: + def _update_pyproject(self, doc: "tomlkit.TOMLDocument", state: State) -> None: """Modify the pyproject.toml document based on mxdev state.""" import tomlkit @@ -106,34 +101,9 @@ def _update_pyproject(self, doc: Any, state: State) -> None: source_table = tomlkit.inline_table() source_table.append("path", rel_path) - if install_mode in ("editable", "direct"): + if install_mode == "editable": 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() - - 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) From da107448a8353c3ce85af198aea23dba96567c13 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Wed, 1 Apr 2026 08:48:09 +0200 Subject: [PATCH 14/21] test: Add missing test coverage and remove obsolete tests --- tests/test_uv.py | 153 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 152 insertions(+), 1 deletion(-) diff --git a/tests/test_uv.py b/tests/test_uv.py index 507c0ff..34d6c09 100644 --- a/tests/test_uv.py +++ b/tests/test_uv.py @@ -112,7 +112,6 @@ def test_hook_executes_when_uv_managed_is_true(mocker, tmp_path, monkeypatch): 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): @@ -157,3 +156,155 @@ def test_update_pyproject_respects_install_modes(tmp_path, monkeypatch): assert sources["editable-pkg"]["editable"] is True assert sources["fixed-pkg"]["editable"] is False assert "skip-pkg" not in sources + + +def test_update_pyproject_idempotency(tmp_path, monkeypatch): + 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) + + initial_toml = """ +[project] +name = "test" +dependencies = [] + +[tool.uv] +managed = true +""" + (tmp_path / "pyproject.toml").write_text(initial_toml.strip()) + + # Run first time + hook.write(state) + content_after_first = (tmp_path / "pyproject.toml").read_text() + + # Run second time + hook.write(state) + content_after_second = (tmp_path / "pyproject.toml").read_text() + + assert content_after_first == content_after_second + + +def test_update_pyproject_with_subdirectory(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + hook = UvPyprojectUpdater() + + mx_ini = """ +[settings] +[pkg1] +url = https://example.com/pkg1.git +target = sources +subdirectory = sub/dir +install-mode = editable +""" + (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()) + assert doc["tool"]["uv"]["sources"]["pkg1"]["path"] == "sources/pkg1/sub/dir" + + +def test_hook_handles_oserror_on_read(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 pyproject.toml with tool.uv.managed = true + initial_toml = """ +[project] +name = "test" + +[tool.uv] +managed = true +""" + (tmp_path / "pyproject.toml").write_text(initial_toml.strip()) + + mock_logger = mocker.patch("mxdev.uv.logger") + mocker.patch("pathlib.Path.open", side_effect=OSError("denied")) + + hook.write(state) + + mock_logger.error.assert_called_with("[%s] Failed to read pyproject.toml: %s", "uv", mocker.ANY) + + +def test_hook_handles_oserror_on_write(mocker, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + hook = UvPyprojectUpdater() + + (tmp_path / "mx.ini").write_text("[settings]") + config = Configuration("mx.ini") + state = State(config) + + initial_toml = """ +[project] +name = "test" + +[tool.uv] +managed = true +""" + (tmp_path / "pyproject.toml").write_text(initial_toml.strip()) + + mock_logger = mocker.patch("mxdev.uv.logger") + mocker.patch("os.replace", side_effect=OSError("write denied")) + + hook.write(state) + + mock_logger.error.assert_called_with("[%s] Failed to write pyproject.toml: %s", "uv", mocker.ANY) + + +import pytest +import sys + + +def test_hook_raises_runtime_error_if_tomlkit_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) + + (tmp_path / "pyproject.toml").write_text("[tool.uv]\\nmanaged = true\\n") + + mocker.patch.dict(sys.modules, {"tomlkit": None}) + # Also need to make the import fail + import builtins + + orig_import = builtins.__import__ + + def fake_import(name, *args, **kw): + if name == "tomlkit": + raise ImportError("No module named 'tomlkit'") + return orig_import(name, *args, **kw) + + mocker.patch("builtins.__import__", side_effect=fake_import) + + with pytest.raises(RuntimeError) as excinfo: + hook.write(state) + + assert "tomlkit is required for the uv hook" in str(excinfo.value) From 6dedfa7dffd84d04c82a5c0601c258aaf855c20e Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Wed, 1 Apr 2026 09:18:31 +0200 Subject: [PATCH 15/21] move imports to the top of the file --- src/mxdev/uv.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/mxdev/uv.py b/src/mxdev/uv.py index 6363919..d97a7fe 100644 --- a/src/mxdev/uv.py +++ b/src/mxdev/uv.py @@ -1,10 +1,10 @@ from mxdev.hooks import Hook from mxdev.state import State from pathlib import Path -from typing import Any import logging -import re +import os +import tempfile logger = logging.getLogger("mxdev") @@ -48,9 +48,6 @@ def write(self, state: State) -> None: self._update_pyproject(doc, state) try: - import os - import tempfile - with tempfile.NamedTemporaryFile( mode="w", dir=pyproject_path.parent, suffix=".tmp", delete=False, encoding="utf-8" ) as f: From 415a59481de169cc27a1cd5042df3577ee1ad6fa Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Wed, 1 Apr 2026 09:19:49 +0200 Subject: [PATCH 16/21] move imports to the top of the file --- tests/test_uv.py | 57 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/tests/test_uv.py b/tests/test_uv.py index 34d6c09..193c53b 100644 --- a/tests/test_uv.py +++ b/tests/test_uv.py @@ -3,6 +3,8 @@ from mxdev.uv import UvPyprojectUpdater import tomlkit +import pytest +import sys def test_hook_skips_when_pyproject_toml_missing(mocker, tmp_path, monkeypatch): @@ -36,7 +38,8 @@ def test_hook_skips_when_uv_managed_is_false_or_missing(mocker, tmp_path, monkey hook.write(state) mock_logger.debug.assert_called_with( - "[%s] Project not explicitly managed by uv ([tool.uv] managed=true missing), skipping.", "uv" + "[%s] Project not explicitly managed by uv ([tool.uv] managed=true missing), skipping.", + "uv", ) # Verify the file was not modified @@ -65,7 +68,8 @@ def test_hook_skips_when_uv_managed_is_false(mocker, tmp_path, monkeypatch): hook.write(state) mock_logger.debug.assert_called_with( - "[%s] Project not explicitly managed by uv ([tool.uv] managed=true missing), skipping.", "uv" + "[%s] Project not explicitly managed by uv ([tool.uv] managed=true missing), skipping.", + "uv", ) # Verify the file was not modified @@ -277,10 +281,6 @@ def test_hook_handles_oserror_on_write(mocker, tmp_path, monkeypatch): mock_logger.error.assert_called_with("[%s] Failed to write pyproject.toml: %s", "uv", mocker.ANY) -import pytest -import sys - - def test_hook_raises_runtime_error_if_tomlkit_missing(mocker, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) hook = UvPyprojectUpdater() @@ -308,3 +308,48 @@ def fake_import(name, *args, **kw): hook.write(state) assert "tomlkit is required for the uv hook" in str(excinfo.value) + + +def test_hook_resolves_path_relative_to_config(mocker, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + config_dir = tmp_path / "other" / "path" + config_dir.mkdir(parents=True) + + mx_ini = """ +[settings] +[pkg1] +url = https://example.com/pkg1.git +target = sources +install-mode = editable +""" + (config_dir / "mx.ini").write_text(mx_ini.strip()) + + config = Configuration(str(config_dir / "mx.ini")) + # Manually mimic the 'directory' injection that happens in including.py + # during actual execution, because Configuration() constructor alone + # doesn't inject it if it isn't in the INI file itself, but including.py does. + config.settings["directory"] = str(config_dir) + state = State(config) + + initial_toml = """ +[project] +name = "test" + +[tool.uv] +managed = true +""" + (config_dir / "pyproject.toml").write_text(initial_toml.strip()) + + hook = UvPyprojectUpdater() + mock_logger = mocker.patch("mxdev.uv.logger") + hook.write(state) + + mock_logger.info.assert_any_call("[%s] Successfully updated pyproject.toml", "uv") + + # Verify the file was written to the config directory, not CWD + assert not (tmp_path / "pyproject.toml").exists() + assert (config_dir / "pyproject.toml").exists() + + doc = tomlkit.parse((config_dir / "pyproject.toml").read_text()) + assert "pkg1" in doc["tool"]["uv"]["sources"] From 24ef1bd2281cfed5482bfb7f42d82c4f841b403b Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Wed, 1 Apr 2026 11:27:37 +0200 Subject: [PATCH 17/21] Use TYPE_CHECKING for tomlkit type hint --- README.md | 2 +- src/mxdev/uv.py | 5 +++++ tests/test_uv.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c691d1a..a5c1164 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,7 @@ Now, use the generated requirements and constraints files with i.e. `pip install ## 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. +mxdev includes a built-in hook to automatically update your `pyproject.toml` file when working with [uv](https://docs.astral.sh/uv/)-managed projects. To use this feature, you must install mxdev with the `uv` extra: diff --git a/src/mxdev/uv.py b/src/mxdev/uv.py index d97a7fe..cbeb5ce 100644 --- a/src/mxdev/uv.py +++ b/src/mxdev/uv.py @@ -1,12 +1,17 @@ from mxdev.hooks import Hook from mxdev.state import State from pathlib import Path +from typing import TYPE_CHECKING import logging import os import tempfile +if TYPE_CHECKING: + import tomlkit + + logger = logging.getLogger("mxdev") diff --git a/tests/test_uv.py b/tests/test_uv.py index 193c53b..dcf8a00 100644 --- a/tests/test_uv.py +++ b/tests/test_uv.py @@ -2,9 +2,9 @@ from mxdev.state import State from mxdev.uv import UvPyprojectUpdater -import tomlkit import pytest import sys +import tomlkit def test_hook_skips_when_pyproject_toml_missing(mocker, tmp_path, monkeypatch): From 86f69c705bb19776fa3092056bbd496eccb31c6c Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Wed, 1 Apr 2026 15:46:59 +0200 Subject: [PATCH 18/21] fix: Defer tomlkit import until uv management is confirmed --- src/mxdev/uv.py | 21 ++++++++++++++------- tests/test_uv.py | 26 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/mxdev/uv.py b/src/mxdev/uv.py index cbeb5ce..19389cd 100644 --- a/src/mxdev/uv.py +++ b/src/mxdev/uv.py @@ -24,23 +24,30 @@ def read(self, state: State) -> None: pass def write(self, state: State) -> None: - try: - import tomlkit - except ImportError: - raise RuntimeError("tomlkit is required for the uv hook. Install it with: pip install mxdev[uv]") - pyproject_path = Path(state.configuration.settings.get("directory", ".")) / "pyproject.toml" 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) + content = pyproject_path.read_text(encoding="utf-8") except OSError as e: logger.error("[%s] Failed to read pyproject.toml: %s", self.namespace, e) return + if "[tool.uv]" not in content: + logger.debug( + "[%s] Project not explicitly managed by uv ([tool.uv] managed=true missing), skipping.", self.namespace + ) + return + + try: + import tomlkit + except ImportError: + raise RuntimeError("tomlkit is required for the uv hook. Install it with: pip install mxdev[uv]") + + doc = tomlkit.loads(content) + # Check for the UV managed signal tool_uv = doc.get("tool", {}).get("uv", {}) if tool_uv.get("managed") is not True: diff --git a/tests/test_uv.py b/tests/test_uv.py index dcf8a00..59c7e9c 100644 --- a/tests/test_uv.py +++ b/tests/test_uv.py @@ -310,6 +310,32 @@ def fake_import(name, *args, **kw): assert "tomlkit is required for the uv hook" in str(excinfo.value) +def test_hook_does_not_require_tomlkit_if_not_uv_managed(mocker, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + hook = UvPyprojectUpdater() + + (tmp_path / "mx.ini").write_text("[settings]") + config = Configuration("mx.ini") + state = State(config) + + (tmp_path / "pyproject.toml").write_text("[project]\\nname = 'test'\\n") + + mocker.patch.dict(sys.modules, {"tomlkit": None}) + import builtins + + orig_import = builtins.__import__ + + def fake_import(name, *args, **kw): + if name == "tomlkit": + raise ImportError("No module named 'tomlkit'") + return orig_import(name, *args, **kw) + + mocker.patch("builtins.__import__", side_effect=fake_import) + + # Should not raise any error, even though tomlkit import is mocked to fail + hook.write(state) + + def test_hook_resolves_path_relative_to_config(mocker, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) From 05df3118473d3ace6092482ce10c27b5f1ce7817 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Wed, 1 Apr 2026 15:49:47 +0200 Subject: [PATCH 19/21] fix: Clean up temporary files on write failure --- src/mxdev/uv.py | 5 +++++ tests/test_uv.py | 3 +++ 2 files changed, 8 insertions(+) diff --git a/src/mxdev/uv.py b/src/mxdev/uv.py index 19389cd..4d5d729 100644 --- a/src/mxdev/uv.py +++ b/src/mxdev/uv.py @@ -59,6 +59,7 @@ def write(self, state: State) -> None: logger.info("[%s] Updating pyproject.toml...", self.namespace) self._update_pyproject(doc, state) + tmp = None try: with tempfile.NamedTemporaryFile( mode="w", dir=pyproject_path.parent, suffix=".tmp", delete=False, encoding="utf-8" @@ -66,9 +67,13 @@ def write(self, state: State) -> None: tomlkit.dump(doc, f) tmp = f.name os.replace(tmp, str(pyproject_path)) + tmp = None # success, don't clean up logger.info("[%s] Successfully updated pyproject.toml", self.namespace) except OSError as e: logger.error("[%s] Failed to write pyproject.toml: %s", self.namespace, e) + finally: + if tmp and os.path.exists(tmp): + os.unlink(tmp) def _update_pyproject(self, doc: "tomlkit.TOMLDocument", state: State) -> None: """Modify the pyproject.toml document based on mxdev state.""" diff --git a/tests/test_uv.py b/tests/test_uv.py index 59c7e9c..7cf4efb 100644 --- a/tests/test_uv.py +++ b/tests/test_uv.py @@ -280,6 +280,9 @@ def test_hook_handles_oserror_on_write(mocker, tmp_path, monkeypatch): mock_logger.error.assert_called_with("[%s] Failed to write pyproject.toml: %s", "uv", mocker.ANY) + # Ensure no .tmp files are left behind + assert len(list(tmp_path.glob("*.tmp"))) == 0 + def test_hook_raises_runtime_error_if_tomlkit_missing(mocker, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) From 49bb647768a8b9c9499397618e479fe60e248ee3 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Wed, 1 Apr 2026 15:51:37 +0200 Subject: [PATCH 20/21] test: Remove misleading relative path resolution test --- tests/test_uv.py | 45 --------------------------------------------- 1 file changed, 45 deletions(-) diff --git a/tests/test_uv.py b/tests/test_uv.py index 7cf4efb..0286302 100644 --- a/tests/test_uv.py +++ b/tests/test_uv.py @@ -337,48 +337,3 @@ def fake_import(name, *args, **kw): # Should not raise any error, even though tomlkit import is mocked to fail hook.write(state) - - -def test_hook_resolves_path_relative_to_config(mocker, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - - config_dir = tmp_path / "other" / "path" - config_dir.mkdir(parents=True) - - mx_ini = """ -[settings] -[pkg1] -url = https://example.com/pkg1.git -target = sources -install-mode = editable -""" - (config_dir / "mx.ini").write_text(mx_ini.strip()) - - config = Configuration(str(config_dir / "mx.ini")) - # Manually mimic the 'directory' injection that happens in including.py - # during actual execution, because Configuration() constructor alone - # doesn't inject it if it isn't in the INI file itself, but including.py does. - config.settings["directory"] = str(config_dir) - state = State(config) - - initial_toml = """ -[project] -name = "test" - -[tool.uv] -managed = true -""" - (config_dir / "pyproject.toml").write_text(initial_toml.strip()) - - hook = UvPyprojectUpdater() - mock_logger = mocker.patch("mxdev.uv.logger") - hook.write(state) - - mock_logger.info.assert_any_call("[%s] Successfully updated pyproject.toml", "uv") - - # Verify the file was written to the config directory, not CWD - assert not (tmp_path / "pyproject.toml").exists() - assert (config_dir / "pyproject.toml").exists() - - doc = tomlkit.parse((config_dir / "pyproject.toml").read_text()) - assert "pkg1" in doc["tool"]["uv"]["sources"] From 2860b1b14c33a64e213e72c42c209f13cfded909 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Wed, 1 Apr 2026 16:11:31 +0200 Subject: [PATCH 21/21] fix: Defer tomlkit import using tomllib fallback strategy --- src/mxdev/uv.py | 31 ++++++++++++++++++++++++++----- tests/test_uv.py | 4 ++-- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/mxdev/uv.py b/src/mxdev/uv.py index 4d5d729..85c678f 100644 --- a/src/mxdev/uv.py +++ b/src/mxdev/uv.py @@ -35,14 +35,35 @@ def write(self, state: State) -> None: logger.error("[%s] Failed to read pyproject.toml: %s", self.namespace, e) return - if "[tool.uv]" not in content: - logger.debug( - "[%s] Project not explicitly managed by uv ([tool.uv] managed=true missing), skipping.", self.namespace - ) + # Attempt to parse using standard library (Python 3.11+) + try: + import tomllib + + parsed = tomllib.loads(content) + if parsed.get("tool", {}).get("uv", {}).get("managed") is not True: + logger.debug( + "[%s] Project not explicitly managed by uv ([tool.uv] managed=true missing), skipping.", + self.namespace, + ) + return + except ImportError: + # Fallback for Python 3.10: fast string check to avoid tomlkit overhead + if "[tool.uv]" not in content: + logger.debug( + "[%s] Project not explicitly managed by uv ([tool.uv] managed=true missing), skipping.", + self.namespace, + ) + return + except Exception: + # If the parser fails (e.g., malformed TOML), just skip. return + # Now we are confident it's a uv project, require our heavy dependency try: - import tomlkit + from typing import TYPE_CHECKING + + if not TYPE_CHECKING: + import tomlkit except ImportError: raise RuntimeError("tomlkit is required for the uv hook. Install it with: pip install mxdev[uv]") diff --git a/tests/test_uv.py b/tests/test_uv.py index 0286302..23b0816 100644 --- a/tests/test_uv.py +++ b/tests/test_uv.py @@ -292,7 +292,7 @@ def test_hook_raises_runtime_error_if_tomlkit_missing(mocker, tmp_path, monkeypa config = Configuration("mx.ini") state = State(config) - (tmp_path / "pyproject.toml").write_text("[tool.uv]\\nmanaged = true\\n") + (tmp_path / "pyproject.toml").write_text("[tool.uv]\nmanaged = true\n") mocker.patch.dict(sys.modules, {"tomlkit": None}) # Also need to make the import fail @@ -321,7 +321,7 @@ def test_hook_does_not_require_tomlkit_if_not_uv_managed(mocker, tmp_path, monke config = Configuration("mx.ini") state = State(config) - (tmp_path / "pyproject.toml").write_text("[project]\\nname = 'test'\\n") + (tmp_path / "pyproject.toml").write_text("[project]\nname = 'test'\n") mocker.patch.dict(sys.modules, {"tomlkit": None}) import builtins