diff --git a/CHANGES.md b/CHANGES.md index 3ce6057..0fd20d3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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]`. 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 21ae6a3..a5c1164 100644 --- a/README.md +++ b/README.md @@ -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,32 @@ 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. + +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 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`. + +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` diff --git a/pyproject.toml b/pyproject.toml index 6733e2d..fe21330 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ 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] @@ -41,6 +43,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" diff --git a/src/mxdev/uv.py b/src/mxdev/uv.py new file mode 100644 index 0000000..cbeb5ce --- /dev/null +++ b/src/mxdev/uv.py @@ -0,0 +1,111 @@ +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") + + +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: + 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) + except OSError 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 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 OSError as e: + logger.error("[%s] Failed to write pyproject.toml: %s", self.namespace, e) + + def _update_pyproject(self, doc: "tomlkit.TOMLDocument", state: State) -> None: + """Modify the pyproject.toml document based on mxdev state.""" + import tomlkit + + 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 == "editable": + source_table.append("editable", True) + elif install_mode == "fixed": + source_table.append("editable", False) + + uv_sources[pkg_name] = source_table diff --git a/tests/test_uv.py b/tests/test_uv.py new file mode 100644 index 0000000..dcf8a00 --- /dev/null +++ b/tests/test_uv.py @@ -0,0 +1,355 @@ +from mxdev.config import Configuration +from mxdev.state import State +from mxdev.uv import UvPyprojectUpdater + +import pytest +import sys +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 + + +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 + + +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) + + +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) + + +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"]