Skip to content

Commit e7444e7

Browse files
jaimergpCzakibrisvag
authored
add uv backend (#172)
Co-authored-by: Grzegorz Bokota <[email protected]> Co-authored-by: Lorenzo Gaifas <[email protected]>
1 parent 232e7d6 commit e7444e7

File tree

5 files changed

+174
-29
lines changed

5 files changed

+174
-29
lines changed

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,12 @@ dev = [
6565
"PyQt6",
6666
"pre-commit",
6767
]
68+
uv = [
69+
"uv",
70+
]
6871

6972
testing = [
73+
"napari-plugin-manager[uv]",
7074
"coverage",
7175
"flaky",
7276
"pytest",

src/napari_plugin_manager/_tests/test_installer_process.py

Lines changed: 59 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
NapariCondaInstallerTool,
2020
NapariInstallerQueue,
2121
NapariPipInstallerTool,
22+
NapariUvInstallerTool,
2223
)
2324

2425
if TYPE_CHECKING:
@@ -60,18 +61,38 @@ def environment(self, env=None):
6061
return QProcessEnvironment.systemEnvironment()
6162

6263

64+
@pytest.fixture
65+
def patch_tool_executable(request):
66+
mp = request.getfixturevalue('monkeypatch')
67+
venv = request.getfixturevalue('tmp_virtualenv')
68+
tool = request.getfixturevalue('tool')
69+
mp.setattr(
70+
tool,
71+
'executable'
72+
if tool == NapariPipInstallerTool
73+
else '_python_executable',
74+
lambda *a: venv.creator.exe,
75+
)
76+
77+
78+
@pytest.mark.parametrize(
79+
'tool', [NapariPipInstallerTool, NapariUvInstallerTool]
80+
)
6381
def test_pip_installer_tasks(
64-
qtbot, tmp_virtualenv: 'Session', monkeypatch, caplog
82+
qtbot,
83+
tool,
84+
tmp_virtualenv: 'Session',
85+
monkeypatch,
86+
caplog,
87+
patch_tool_executable,
6588
):
6689
caplog.set_level(logging.DEBUG, logger=bqpi.__name__)
67-
installer = NapariInstallerQueue()
6890
monkeypatch.setattr(
69-
NapariPipInstallerTool,
70-
'executable',
71-
lambda *a: tmp_virtualenv.creator.exe,
91+
NapariInstallerQueue, 'PYPI_INSTALLER_TOOL_CLASS', tool
7292
)
93+
installer = NapariInstallerQueue()
7394
monkeypatch.setattr(
74-
NapariPipInstallerTool,
95+
tool,
7596
'origins',
7697
('https://pypi.org/simple',),
7798
)
@@ -140,13 +161,16 @@ def test_pip_installer_tasks(
140161
)
141162

142163

143-
def test_pip_installer_invalid_action(tmp_virtualenv: 'Session', monkeypatch):
144-
installer = NapariInstallerQueue()
164+
@pytest.mark.parametrize(
165+
'tool', [NapariPipInstallerTool, NapariUvInstallerTool]
166+
)
167+
def test_pip_installer_invalid_action(
168+
tool, tmp_virtualenv: 'Session', monkeypatch, patch_tool_executable
169+
):
145170
monkeypatch.setattr(
146-
NapariPipInstallerTool,
147-
'executable',
148-
lambda *a: tmp_virtualenv.creator.exe,
171+
NapariInstallerQueue, 'PYPI_INSTALLER_TOOL_CLASS', tool
149172
)
173+
installer = NapariInstallerQueue()
150174
invalid_action = 'Invalid Action'
151175
item = installer._build_queue_item(
152176
tool=InstallerTools.PYPI,
@@ -162,13 +186,16 @@ def test_pip_installer_invalid_action(tmp_virtualenv: 'Session', monkeypatch):
162186
installer._queue_item(item)
163187

164188

165-
def test_installer_failures(qtbot, tmp_virtualenv: 'Session', monkeypatch):
166-
installer = NapariInstallerQueue()
189+
@pytest.mark.parametrize(
190+
'tool', [NapariPipInstallerTool, NapariUvInstallerTool]
191+
)
192+
def test_installer_failures(
193+
tool, qtbot, tmp_virtualenv: 'Session', monkeypatch, patch_tool_executable
194+
):
167195
monkeypatch.setattr(
168-
NapariPipInstallerTool,
169-
'executable',
170-
lambda *a: tmp_virtualenv.creator.exe,
196+
NapariInstallerQueue, 'PYPI_INSTALLER_TOOL_CLASS', tool
171197
)
198+
installer = NapariInstallerQueue()
172199

173200
# CHECK 1) Errors should trigger finished and allFinished too
174201
with qtbot.waitSignal(installer.allFinished, timeout=10_000):
@@ -206,7 +233,13 @@ def test_installer_failures(qtbot, tmp_virtualenv: 'Session', monkeypatch):
206233
)
207234

208235

209-
def test_cancel_incorrect_job_id(qtbot, tmp_virtualenv: 'Session'):
236+
@pytest.mark.parametrize(
237+
'tool', [NapariPipInstallerTool, NapariUvInstallerTool]
238+
)
239+
def test_cancel_incorrect_job_id(tool, qtbot, monkeypatch):
240+
monkeypatch.setattr(
241+
NapariInstallerQueue, 'PYPI_INSTALLER_TOOL_CLASS', tool
242+
)
210243
installer = NapariInstallerQueue()
211244
with qtbot.waitSignal(installer.allFinished, timeout=30_000):
212245
job_id = installer.install(
@@ -306,13 +339,15 @@ def test_conda_installer(qtbot, caplog, monkeypatch, tmp_conda_env: Path):
306339
assert not installer.hasJobs()
307340

308341

309-
def test_installer_error(qtbot, tmp_virtualenv: 'Session', monkeypatch):
310-
installer = NapariInstallerQueue()
342+
@pytest.mark.parametrize(
343+
'tool', [NapariPipInstallerTool, NapariUvInstallerTool]
344+
)
345+
def test_installer_error(qtbot, tool, monkeypatch):
311346
monkeypatch.setattr(
312-
NapariPipInstallerTool,
313-
'executable',
314-
lambda *a: 'not-a-real-executable',
347+
NapariInstallerQueue, 'PYPI_INSTALLER_TOOL_CLASS', tool
315348
)
349+
installer = NapariInstallerQueue()
350+
monkeypatch.setattr(tool, 'executable', lambda *a: 'not-a-real-executable')
316351
with qtbot.waitSignal(installer.allFinished, timeout=600_000):
317352
installer.install(
318353
tool=InstallerTools.PYPI,
@@ -358,11 +393,13 @@ def test_constraints_are_in_sync():
358393
def test_executables():
359394
assert NapariCondaInstallerTool.executable()
360395
assert NapariPipInstallerTool.executable()
396+
assert NapariUvInstallerTool.executable()
361397

362398

363399
def test_available():
364400
assert str(NapariCondaInstallerTool.available())
365401
assert NapariPipInstallerTool.available()
402+
assert NapariUvInstallerTool.available()
366403

367404

368405
def test_unrecognized_tool():

src/napari_plugin_manager/_tests/test_qt_plugin_dialog.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,7 @@ def test_install_pypi_constructor(
512512
monkeypatch.setattr(
513513
qt_plugin_dialog.PluginListItem,
514514
'get_installer_source',
515-
lambda self: 'PIP',
515+
lambda self: 'PyPI',
516516
)
517517

518518
plugin_dialog.set_prefix(str(tmp_virtualenv))

src/napari_plugin_manager/base_qt_package_installer.py

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from functools import lru_cache
2121
from logging import getLogger
2222
from pathlib import Path
23-
from subprocess import call
23+
from subprocess import run
2424
from tempfile import gettempdir
2525
from typing import TypedDict
2626

@@ -122,7 +122,10 @@ class PipInstallerTool(AbstractInstallerTool):
122122
@classmethod
123123
def available(cls) -> bool:
124124
"""Check if pip is available."""
125-
return call([cls.executable(), '-m', 'pip', '--version']) == 0
125+
process = run(
126+
[cls.executable(), '-m', 'pip', '--version'], capture_output=True
127+
)
128+
return process.returncode == 0
126129

127130
def arguments(self) -> list[str]:
128131
"""Compose arguments for the pip command."""
@@ -149,7 +152,7 @@ def arguments(self) -> list[str]:
149152
else:
150153
raise ValueError(f"Action '{self.action}' not supported!")
151154

152-
if log.getEffectiveLevel() < 30: # DEBUG and INFOlevel
155+
if log.getEffectiveLevel() < 30: # DEBUG and INFO level
153156
args.append('-vvv')
154157

155158
if self.prefix is not None:
@@ -171,6 +174,79 @@ def _constraints_file(cls) -> str:
171174
raise NotImplementedError
172175

173176

177+
class UvInstallerTool(AbstractInstallerTool):
178+
"""Uv installer tool for the plugin manager.
179+
180+
This class is used to install and uninstall packages using uv.
181+
"""
182+
183+
@classmethod
184+
def executable(cls) -> str:
185+
"Path to the executable that will run the task"
186+
if sys.platform == 'win32':
187+
path = os.path.join(sys.prefix, 'Scripts', 'uv.exe')
188+
else:
189+
path = os.path.join(sys.prefix, 'bin', 'uv')
190+
if os.path.isfile(path):
191+
return path
192+
return 'uv'
193+
194+
@classmethod
195+
def available(cls) -> bool:
196+
"""Check if uv is available."""
197+
try:
198+
process = run([cls.executable(), '--version'], capture_output=True)
199+
except FileNotFoundError: # pragma: no cover
200+
return False
201+
else:
202+
return process.returncode == 0
203+
204+
def arguments(self) -> list[str]:
205+
"""Compose arguments for the uv pip command."""
206+
args = ['pip']
207+
208+
if self.action == InstallerActions.INSTALL:
209+
args += ['install', '-c', self._constraints_file()]
210+
for origin in self.origins:
211+
args += ['--extra-index-url', origin]
212+
213+
elif self.action == InstallerActions.UPGRADE:
214+
args += ['install', '-c', self._constraints_file()]
215+
for origin in self.origins:
216+
args += ['--extra-index-url', origin]
217+
for pkg in self.pkgs:
218+
args.append(f'--upgrade-package={pkg}')
219+
elif self.action == InstallerActions.UNINSTALL:
220+
args += ['uninstall']
221+
222+
else:
223+
raise ValueError(f"Action '{self.action}' not supported!")
224+
225+
if log.getEffectiveLevel() < 30: # DEBUG and INFO level
226+
args.append('-vvv')
227+
228+
if self.prefix is not None:
229+
args.extend(['--prefix', str(self.prefix)])
230+
args.extend(['--python', self._python_executable()])
231+
232+
return [*args, *self.pkgs]
233+
234+
def environment(
235+
self, env: QProcessEnvironment = None
236+
) -> QProcessEnvironment:
237+
if env is None:
238+
env = QProcessEnvironment.systemEnvironment()
239+
return env
240+
241+
@classmethod
242+
@lru_cache(maxsize=0)
243+
def _constraints_file(cls) -> str:
244+
raise NotImplementedError
245+
246+
def _python_executable(self) -> str:
247+
raise NotImplementedError
248+
249+
174250
class CondaInstallerTool(AbstractInstallerTool):
175251
"""Conda installer tool for the plugin manager.
176252
@@ -199,11 +275,12 @@ def executable(cls) -> str:
199275
@classmethod
200276
def available(cls) -> bool:
201277
"""Check if the executable is available by checking if it can output its version."""
202-
executable = cls.executable()
203278
try:
204-
return call([executable, '--version']) == 0
279+
process = run([cls.executable(), '--version'], capture_output=True)
205280
except FileNotFoundError: # pragma: no cover
206281
return False
282+
else:
283+
return process.returncode == 0
207284

208285
def arguments(self) -> list[str]:
209286
"""Compose arguments for the conda command."""

src/napari_plugin_manager/qt_package_installer.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
CondaInstallerTool,
2424
InstallerQueue,
2525
PipInstallerTool,
26+
UvInstallerTool,
2627
)
2728

2829

@@ -62,6 +63,28 @@ def _constraints_file(cls) -> str:
6263
return f.name
6364

6465

66+
class NapariUvInstallerTool(UvInstallerTool):
67+
@staticmethod
68+
def constraints() -> list[str]:
69+
"""
70+
Version constraints to limit unwanted changes in installation.
71+
"""
72+
return [f'napari=={_napari_version}']
73+
74+
@classmethod
75+
@lru_cache(maxsize=0)
76+
def _constraints_file(cls) -> str:
77+
with NamedTemporaryFile(
78+
'w', suffix='-napari-constraints.txt', delete=False
79+
) as f:
80+
f.write('\n'.join(cls.constraints()))
81+
atexit.register(os.unlink, f.name)
82+
return f.name
83+
84+
def _python_executable(self) -> str:
85+
return str(_get_python_exe())
86+
87+
6588
class NapariCondaInstallerTool(CondaInstallerTool):
6689
@staticmethod
6790
def constraints() -> list[str]:
@@ -80,6 +103,10 @@ def constraints() -> list[str]:
80103

81104

82105
class NapariInstallerQueue(InstallerQueue):
83-
PYPI_INSTALLER_TOOL_CLASS = NapariPipInstallerTool
106+
PYPI_INSTALLER_TOOL_CLASS = (
107+
NapariUvInstallerTool
108+
if NapariUvInstallerTool.available()
109+
else NapariPipInstallerTool
110+
)
84111
CONDA_INSTALLER_TOOL_CLASS = NapariCondaInstallerTool
85112
BASE_PACKAGE_NAME = 'napari'

0 commit comments

Comments
 (0)