@@ -112,7 +112,6 @@ def test_hook_executes_when_uv_managed_is_true(mocker, tmp_path, monkeypatch):
112112 assert "pkg1" in doc ["tool" ]["uv" ]["sources" ]
113113 assert doc ["tool" ]["uv" ]["sources" ]["pkg1" ]["path" ] == "sources/pkg1"
114114 assert doc ["tool" ]["uv" ]["sources" ]["pkg1" ]["editable" ] is True
115- assert "pkg1" in doc ["project" ]["dependencies" ]
116115
117116
118117def test_update_pyproject_respects_install_modes (tmp_path , monkeypatch ):
@@ -157,3 +156,155 @@ def test_update_pyproject_respects_install_modes(tmp_path, monkeypatch):
157156 assert sources ["editable-pkg" ]["editable" ] is True
158157 assert sources ["fixed-pkg" ]["editable" ] is False
159158 assert "skip-pkg" not in sources
159+
160+
161+ def test_update_pyproject_idempotency (tmp_path , monkeypatch ):
162+ monkeypatch .chdir (tmp_path )
163+ hook = UvPyprojectUpdater ()
164+
165+ mx_ini = """
166+ [settings]
167+ [pkg1]
168+ url = https://example.com/pkg1.git
169+ target = sources
170+ install-mode = editable
171+ """
172+ (tmp_path / "mx.ini" ).write_text (mx_ini .strip ())
173+ config = Configuration ("mx.ini" )
174+ state = State (config )
175+
176+ initial_toml = """
177+ [project]
178+ name = "test"
179+ dependencies = []
180+
181+ [tool.uv]
182+ managed = true
183+ """
184+ (tmp_path / "pyproject.toml" ).write_text (initial_toml .strip ())
185+
186+ # Run first time
187+ hook .write (state )
188+ content_after_first = (tmp_path / "pyproject.toml" ).read_text ()
189+
190+ # Run second time
191+ hook .write (state )
192+ content_after_second = (tmp_path / "pyproject.toml" ).read_text ()
193+
194+ assert content_after_first == content_after_second
195+
196+
197+ def test_update_pyproject_with_subdirectory (tmp_path , monkeypatch ):
198+ monkeypatch .chdir (tmp_path )
199+ hook = UvPyprojectUpdater ()
200+
201+ mx_ini = """
202+ [settings]
203+ [pkg1]
204+ url = https://example.com/pkg1.git
205+ target = sources
206+ subdirectory = sub/dir
207+ install-mode = editable
208+ """
209+ (tmp_path / "mx.ini" ).write_text (mx_ini .strip ())
210+ config = Configuration ("mx.ini" )
211+ state = State (config )
212+
213+ initial_toml = """
214+ [project]
215+ name = "test"
216+ dependencies = []
217+
218+ [tool.uv]
219+ managed = true
220+ """
221+ (tmp_path / "pyproject.toml" ).write_text (initial_toml .strip ())
222+
223+ hook .write (state )
224+
225+ doc = tomlkit .parse ((tmp_path / "pyproject.toml" ).read_text ())
226+ assert doc ["tool" ]["uv" ]["sources" ]["pkg1" ]["path" ] == "sources/pkg1/sub/dir"
227+
228+
229+ def test_hook_handles_oserror_on_read (mocker , tmp_path , monkeypatch ):
230+ monkeypatch .chdir (tmp_path )
231+ hook = UvPyprojectUpdater ()
232+
233+ (tmp_path / "mx.ini" ).write_text ("[settings]" )
234+ config = Configuration ("mx.ini" )
235+ state = State (config )
236+
237+ # Mock pyproject.toml with tool.uv.managed = true
238+ initial_toml = """
239+ [project]
240+ name = "test"
241+
242+ [tool.uv]
243+ managed = true
244+ """
245+ (tmp_path / "pyproject.toml" ).write_text (initial_toml .strip ())
246+
247+ mock_logger = mocker .patch ("mxdev.uv.logger" )
248+ mocker .patch ("pathlib.Path.open" , side_effect = OSError ("denied" ))
249+
250+ hook .write (state )
251+
252+ mock_logger .error .assert_called_with ("[%s] Failed to read pyproject.toml: %s" , "uv" , mocker .ANY )
253+
254+
255+ def test_hook_handles_oserror_on_write (mocker , tmp_path , monkeypatch ):
256+ monkeypatch .chdir (tmp_path )
257+ hook = UvPyprojectUpdater ()
258+
259+ (tmp_path / "mx.ini" ).write_text ("[settings]" )
260+ config = Configuration ("mx.ini" )
261+ state = State (config )
262+
263+ initial_toml = """
264+ [project]
265+ name = "test"
266+
267+ [tool.uv]
268+ managed = true
269+ """
270+ (tmp_path / "pyproject.toml" ).write_text (initial_toml .strip ())
271+
272+ mock_logger = mocker .patch ("mxdev.uv.logger" )
273+ mocker .patch ("os.replace" , side_effect = OSError ("write denied" ))
274+
275+ hook .write (state )
276+
277+ mock_logger .error .assert_called_with ("[%s] Failed to write pyproject.toml: %s" , "uv" , mocker .ANY )
278+
279+
280+ import pytest
281+ import sys
282+
283+
284+ def test_hook_raises_runtime_error_if_tomlkit_missing (mocker , tmp_path , monkeypatch ):
285+ monkeypatch .chdir (tmp_path )
286+ hook = UvPyprojectUpdater ()
287+
288+ (tmp_path / "mx.ini" ).write_text ("[settings]" )
289+ config = Configuration ("mx.ini" )
290+ state = State (config )
291+
292+ (tmp_path / "pyproject.toml" ).write_text ("[tool.uv]\\ nmanaged = true\\ n" )
293+
294+ mocker .patch .dict (sys .modules , {"tomlkit" : None })
295+ # Also need to make the import fail
296+ import builtins
297+
298+ orig_import = builtins .__import__
299+
300+ def fake_import (name , * args , ** kw ):
301+ if name == "tomlkit" :
302+ raise ImportError ("No module named 'tomlkit'" )
303+ return orig_import (name , * args , ** kw )
304+
305+ mocker .patch ("builtins.__import__" , side_effect = fake_import )
306+
307+ with pytest .raises (RuntimeError ) as excinfo :
308+ hook .write (state )
309+
310+ assert "tomlkit is required for the uv hook" in str (excinfo .value )
0 commit comments