Skip to content

Commit ca79af1

Browse files
committed
Fix Windows uv self-update by deferring install until process exits
1 parent 97b1157 commit ca79af1

2 files changed

Lines changed: 117 additions & 5 deletions

File tree

seiscat/self/update.py

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,48 @@ def _schedule_windows_pip_uninstall():
7070
])
7171

7272

73+
def _schedule_windows_uv_tool_install(package_spec):
74+
"""Schedule uv tool install after current process exits (Windows only)."""
75+
helper_code = (
76+
'import ctypes, subprocess, sys, time\n'
77+
'pid = int(sys.argv[1])\n'
78+
'spec = sys.argv[2]\n'
79+
'sync = 0x00100000\n'
80+
'infinite = 0xFFFFFFFF\n'
81+
'kernel32 = ctypes.windll.kernel32\n'
82+
'handle = kernel32.OpenProcess(sync, False, pid)\n'
83+
'if handle:\n'
84+
' kernel32.WaitForSingleObject(handle, infinite)\n'
85+
' kernel32.CloseHandle(handle)\n'
86+
'else:\n'
87+
' time.sleep(1)\n'
88+
'cmd = ["uv", "tool", "install", spec, "--upgrade", "--force"]\n'
89+
'for _ in range(8):\n'
90+
' if subprocess.run(cmd, check=False).returncode == 0:\n'
91+
' break\n'
92+
' time.sleep(1)\n'
93+
)
94+
subprocess.Popen([
95+
sys.executable,
96+
'-c',
97+
helper_code,
98+
str(os.getpid()),
99+
package_spec,
100+
])
101+
102+
73103
def _pip_update_release():
74104
_run_checked([
75105
sys.executable, '-m', 'pip', 'install', '--upgrade', 'seiscat'
76106
])
77107

78108

79109
def _uv_update_release():
110+
if os.name == 'nt':
111+
_schedule_windows_uv_tool_install('seiscat')
112+
return True
80113
_run_checked(['uv', 'tool', 'install', 'seiscat', '--upgrade', '--force'])
114+
return False
81115

82116

83117
def _pip_update_git():
@@ -89,9 +123,13 @@ def _pip_update_git():
89123

90124
def _uv_update_git():
91125
git_spec = _git_install_spec_with_extras()
126+
if os.name == 'nt':
127+
_schedule_windows_uv_tool_install(git_spec)
128+
return True
92129
_run_checked([
93130
'uv', 'tool', 'install', git_spec, '--upgrade', '--force'
94131
])
132+
return False
95133

96134

97135
def _is_release_higher(installed_version, latest_release_version):
@@ -115,7 +153,12 @@ def update_seiscat(git=False):
115153

116154
if git:
117155
if context.installer == 'uv' and uv_available:
118-
_uv_update_git()
156+
deferred = _uv_update_git()
157+
if deferred:
158+
return (
159+
'Windows detected: git update has been scheduled via uv '
160+
'and will run after seiscat exits.'
161+
)
119162
return 'Updated to latest git version using uv.'
120163
_pip_update_git()
121164
return 'Updated to latest git version using pip.'
@@ -125,7 +168,15 @@ def update_seiscat(git=False):
125168
if _is_release_higher(context.version_installed, latest_release):
126169
# Switch back to release because release is newer
127170
if context.installer == 'uv' and uv_available:
128-
_uv_update_release()
171+
deferred = _uv_update_release()
172+
if deferred:
173+
return (
174+
f'Latest release ({latest_release}) '
175+
'is newer than installed git '
176+
f'version ({context.version_installed}); '
177+
'switch to release has been scheduled via uv '
178+
'and will run after seiscat exits.'
179+
)
129180
return (
130181
f'Latest release ({latest_release}) '
131182
'is newer than installed git '
@@ -159,7 +210,12 @@ def update_seiscat(git=False):
159210
)
160211

161212
if context.installer == 'uv' and uv_available:
162-
_uv_update_release()
213+
deferred = _uv_update_release()
214+
if deferred:
215+
return (
216+
'Windows detected: release update has been scheduled via uv '
217+
'and will run after seiscat exits.'
218+
)
163219
return 'Updated to latest release using uv.'
164220
_pip_update_release()
165221
return 'Updated to latest release using pip.'

tests/test_self_update_policy.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77

88
from seiscat.self.install_detection import InstallContext
99
from seiscat.self.update import (
10+
_schedule_windows_uv_tool_install,
1011
_pip_update_git,
1112
_uv_update_git,
13+
_uv_update_release,
1214
uninstall_seiscat,
1315
update_seiscat,
1416
)
@@ -18,7 +20,7 @@ class TestSelfUpdatePolicy(unittest.TestCase):
1820
"""Validate release/git policy for `seiscat self update`."""
1921

2022
@patch('seiscat.self.update.shutil.which', return_value='/usr/bin/uv')
21-
@patch('seiscat.self.update._uv_update_release')
23+
@patch('seiscat.self.update._uv_update_release', return_value=False)
2224
@patch('seiscat.self.update.detect_install_context')
2325
@patch(
2426
'seiscat.self.update.get_latest_release_version',
@@ -172,9 +174,11 @@ def test_pip_update_git_installs_extras(self, mock_run_checked):
172174
])
173175

174176
@patch('seiscat.self.update._run_checked')
177+
@patch('seiscat.self.update.os.name', 'posix')
175178
def test_uv_update_git_installs_extras(self, mock_run_checked):
176-
_uv_update_git()
179+
deferred = _uv_update_git()
177180

181+
self.assertFalse(deferred)
178182
mock_run_checked.assert_called_once_with([
179183
'uv',
180184
'tool',
@@ -184,3 +188,55 @@ def test_uv_update_git_installs_extras(self, mock_run_checked):
184188
'--upgrade',
185189
'--force',
186190
])
191+
192+
@patch('seiscat.self.update._schedule_windows_uv_tool_install')
193+
@patch('seiscat.self.update.os.name', 'nt')
194+
def test_uv_update_git_is_deferred_on_windows(self, mock_schedule):
195+
deferred = _uv_update_git()
196+
197+
self.assertTrue(deferred)
198+
mock_schedule.assert_called_once_with(
199+
'seiscat[cartopy,plotly,folium] @ '
200+
'git+https://github.com/SeismicSource/seiscat.git'
201+
)
202+
203+
@patch('seiscat.self.update._schedule_windows_uv_tool_install')
204+
@patch('seiscat.self.update.os.name', 'nt')
205+
def test_uv_update_release_is_deferred_on_windows(self, mock_schedule):
206+
deferred = _uv_update_release()
207+
208+
self.assertTrue(deferred)
209+
mock_schedule.assert_called_once_with('seiscat')
210+
211+
@patch('seiscat.self.update.shutil.which', return_value='uv')
212+
@patch('seiscat.self.update.detect_install_context')
213+
@patch('seiscat.self.update._uv_update_git', return_value=True)
214+
def test_update_git_reports_scheduled_on_windows(
215+
self,
216+
_mock_uv_update_git,
217+
mock_detect,
218+
_mock_which):
219+
mock_detect.return_value = InstallContext(
220+
installer='uv',
221+
channel='release',
222+
version_installed='0.9.1',
223+
source_url=None,
224+
editable=False,
225+
confidence='high',
226+
)
227+
228+
msg = update_seiscat(git=True)
229+
self.assertIn('scheduled', msg)
230+
231+
@patch('seiscat.self.update.os.getpid', return_value=1234)
232+
@patch('seiscat.self.update.sys.executable', 'C:/Python/python.exe')
233+
@patch('seiscat.self.update.subprocess.Popen')
234+
def test_windows_uv_helper_uses_retry_loop(self, mock_popen, _mock_getpid):
235+
_schedule_windows_uv_tool_install('seiscat')
236+
237+
mock_popen.assert_called_once()
238+
cmd = mock_popen.call_args[0][0]
239+
self.assertEqual(cmd[0], 'C:/Python/python.exe')
240+
self.assertEqual(cmd[1], '-c')
241+
self.assertIn('for _ in range(8):', cmd[2])
242+
self.assertIn('time.sleep(1)', cmd[2])

0 commit comments

Comments
 (0)