-
-
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
Merged
+519
−1
Merged
Changes from 10 commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
a4e0ab3
build: Add tomlkit as a core dependency for uv support
erral 3f94e9b
feat: Merge UvPyprojectUpdater hook directly into mxdev core
erral ba6ee86
test: Add proper tests for UvPyprojectUpdater hook with managed=true …
erral 6e3d691
docs: Document UV pyproject updater integration
erral 2670512
fix: Update entrypoint key to 'hook' and attribute contributor in CHA…
erral 5ca157c
test: Add specific test for managed=false skipping
erral b5b0637
docs: Move and expand UV Pyproject Integration documentation
erral ca05f18
docs: Remove duplicate UV Pyproject Integration section
erral c2b3360
test: Rework test_hook_skips_when_pyproject_toml_missing to use tmp_path
erral 645f5f7
Update README.md
erral bed9e2c
build: Make tomlkit optional and update documentation per PR review
erral 24c505b
refactor: Lazy load tomlkit, use atomic writes, and fix path resolution
erral 0a07ad3
refactor: Remove project.dependencies mutation and clean up dead code
erral da10744
test: Add missing test coverage and remove obsolete tests
erral 6dedfa7
move imports to the top of the file
erral 415a594
move imports to the top of the file
erral 24ef1bd
Use TYPE_CHECKING for tomlkit type hint
erral 86f69c7
fix: Defer tomlkit import until uv management is confirmed
erral 05df311
fix: Clean up temporary files on write failure
erral 49bb647
test: Remove misleading relative path resolution test
erral 2860b1b
fix: Defer tomlkit import using tomllib fallback strategy
erral File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
erral marked this conversation as resolved.
Outdated
|
||
|
|
||
| import logging | ||
| import re | ||
| import tomlkit | ||
|
erral marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| logger = logging.getLogger("mxdev") | ||
|
|
||
|
|
||
| def normalize_name(name: str) -> str: | ||
| """PEP 503 normalization: lowercased, runs of -, _, . become single -""" | ||
| return re.sub(r"[-_.]+", "-", name).lower() | ||
|
erral marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| 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 | ||
|
|
||
|
erral marked this conversation as resolved.
|
||
| def write(self, state: State) -> None: | ||
| pyproject_path = Path("pyproject.toml") | ||
|
erral marked this conversation as resolved.
Outdated
|
||
| 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) | ||
|
erral marked this conversation as resolved.
|
||
| 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) | ||
|
|
||
|
erral marked this conversation as resolved.
|
||
| def _update_pyproject(self, doc: Any, state: State) -> None: | ||
| """Modify the pyproject.toml document based on mxdev state.""" | ||
|
erral marked this conversation as resolved.
|
||
| 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() | ||
|
|
||
|
erral marked this conversation as resolved.
|
||
| 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() | ||
|
erral marked this conversation as resolved.
Outdated
|
||
|
|
||
| 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) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
erral marked this conversation as resolved.
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.