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