From 7b6687096f9e07c75d5c092ea509e4c06abe9c2e Mon Sep 17 00:00:00 2001 From: jaimergp Date: Mon, 11 Aug 2025 11:54:03 +0200 Subject: [PATCH 1/8] add uv backend --- pyproject.toml | 1 + .../_tests/test_installer_process.py | 98 +++++++++++++------ .../_tests/test_qt_plugin_dialog.py | 4 +- .../base_qt_package_installer.py | 95 ++++++++++++++++-- .../base_qt_plugin_dialog.py | 10 +- .../qt_package_installer.py | 29 +++++- src/napari_plugin_manager/qt_plugin_dialog.py | 2 +- 7 files changed, 190 insertions(+), 49 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 181d18f..405c356 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ dependencies = [ "superqt", "pip", "packaging", + "uv", ] dynamic = [ "version" diff --git a/src/napari_plugin_manager/_tests/test_installer_process.py b/src/napari_plugin_manager/_tests/test_installer_process.py index 9a6efa2..1957f62 100644 --- a/src/napari_plugin_manager/_tests/test_installer_process.py +++ b/src/napari_plugin_manager/_tests/test_installer_process.py @@ -19,6 +19,7 @@ NapariCondaInstallerTool, NapariInstallerQueue, NapariPipInstallerTool, + NapariUvInstallerTool, ) if TYPE_CHECKING: @@ -60,32 +61,39 @@ def environment(self, env=None): return QProcessEnvironment.systemEnvironment() +@pytest.mark.parametrize( + 'tool', [NapariPipInstallerTool, NapariUvInstallerTool] +) def test_pip_installer_tasks( - qtbot, tmp_virtualenv: 'Session', monkeypatch, caplog + qtbot, tool, tmp_virtualenv: 'Session', monkeypatch, caplog ): caplog.set_level(logging.DEBUG, logger=bqpi.__name__) + monkeypatch.setattr(NapariInstallerQueue, 'PYPI_INSTALLER_TOOL_CLASS', tool) installer = NapariInstallerQueue() monkeypatch.setattr( - NapariPipInstallerTool, - 'executable', + tool, + 'executable' + if tool == NapariPipInstallerTool + else '_python_executable', lambda *a: tmp_virtualenv.creator.exe, ) - monkeypatch.setattr( - NapariPipInstallerTool, - 'origins', - ('https://pypi.org/simple',), - ) + if tool == NapariPipInstallerTool: + monkeypatch.setattr( + NapariPipInstallerTool, + 'origins', + ('https://pypi.org/simple',), + ) with qtbot.waitSignal(installer.allFinished, timeout=30_000): installer.install( - tool=InstallerTools.PIP, + tool=InstallerTools.PYPI, pkgs=['pip-install-test'], ) installer.install( - tool=InstallerTools.PIP, + tool=InstallerTools.PYPI, pkgs=['typing-extensions'], ) job_id = installer.install( - tool=InstallerTools.PIP, + tool=InstallerTools.PYPI, pkgs=['requests'], ) assert isinstance(job_id, int) @@ -106,7 +114,7 @@ def test_pip_installer_tasks( with qtbot.waitSignal(installer.allFinished, timeout=30_000): job_id = installer.uninstall( - tool=InstallerTools.PIP, + tool=InstallerTools.PYPI, pkgs=['pip-install-test'], ) @@ -121,7 +129,7 @@ def test_pip_installer_tasks( installer.processFinished, timeout=30_000 ) as blocker: installer.install( - tool=InstallerTools.PIP, + tool=InstallerTools.PYPI, pkgs=['pydantic'], ) process_finished_data = blocker.args[0] @@ -131,25 +139,33 @@ def test_pip_installer_tasks( # Test upgrade with qtbot.waitSignal(installer.allFinished, timeout=30_000): installer.install( - tool=InstallerTools.PIP, + tool=InstallerTools.PYPI, pkgs=['requests==2.30.0'], ) installer.upgrade( - tool=InstallerTools.PIP, + tool=InstallerTools.PYPI, pkgs=['requests'], ) -def test_pip_installer_invalid_action(tmp_virtualenv: 'Session', monkeypatch): - installer = NapariInstallerQueue() +@pytest.mark.parametrize( + 'tool', [NapariPipInstallerTool, NapariUvInstallerTool] +) +def test_pip_installer_invalid_action( + tool, tmp_virtualenv: 'Session', monkeypatch +): + monkeypatch.setattr(NapariInstallerQueue, 'PYPI_INSTALLER_TOOL_CLASS', tool) monkeypatch.setattr( - NapariPipInstallerTool, - 'executable', + tool, + 'executable' + if tool == NapariPipInstallerTool + else '_python_executable', lambda *a: tmp_virtualenv.creator.exe, ) + installer = NapariInstallerQueue() invalid_action = 'Invalid Action' item = installer._build_queue_item( - tool=InstallerTools.PIP, + tool=InstallerTools.PYPI, action=invalid_action, pkgs=['pip-install-test'], prefix=None, @@ -162,18 +178,26 @@ def test_pip_installer_invalid_action(tmp_virtualenv: 'Session', monkeypatch): installer._queue_item(item) -def test_installer_failures(qtbot, tmp_virtualenv: 'Session', monkeypatch): +@pytest.mark.parametrize( + 'tool', [NapariPipInstallerTool, NapariUvInstallerTool] +) +def test_installer_failures( + tool, qtbot, tmp_virtualenv: 'Session', monkeypatch +): + monkeypatch.setattr(NapariInstallerQueue, 'PYPI_INSTALLER_TOOL_CLASS', tool) installer = NapariInstallerQueue() monkeypatch.setattr( - NapariPipInstallerTool, - 'executable', + tool, + 'executable' + if tool == NapariPipInstallerTool + else '_python_executable', lambda *a: tmp_virtualenv.creator.exe, ) # CHECK 1) Errors should trigger finished and allFinished too with qtbot.waitSignal(installer.allFinished, timeout=10_000): installer.install( - tool=InstallerTools.PIP, + tool=InstallerTools.PYPI, pkgs=[f'this-package-does-not-exist-{hash(time.time())}'], ) @@ -188,7 +212,7 @@ def test_installer_failures(qtbot, tmp_virtualenv: 'Session', monkeypatch): ) with qtbot.waitSignal(installer.allFinished, timeout=10_000): installer.install( - tool=InstallerTools.PIP, + tool=InstallerTools.PYPI, pkgs=[f'this-package-does-not-exist-{hash(time.time())}'], ) @@ -206,11 +230,15 @@ def test_installer_failures(qtbot, tmp_virtualenv: 'Session', monkeypatch): ) -def test_cancel_incorrect_job_id(qtbot, tmp_virtualenv: 'Session'): +@pytest.mark.parametrize( + 'tool', [NapariPipInstallerTool, NapariUvInstallerTool] +) +def test_cancel_incorrect_job_id(tool, qtbot, monkeypatch): + monkeypatch.setattr(NapariInstallerQueue, 'PYPI_INSTALLER_TOOL_CLASS', tool) installer = NapariInstallerQueue() with qtbot.waitSignal(installer.allFinished, timeout=30_000): job_id = installer.install( - tool=InstallerTools.PIP, + tool=InstallerTools.PYPI, pkgs=['requests'], ) with pytest.raises(ValueError, match=f'No job with id {job_id + 1}.'): @@ -306,16 +334,22 @@ def test_conda_installer(qtbot, caplog, monkeypatch, tmp_conda_env: Path): assert not installer.hasJobs() -def test_installer_error(qtbot, tmp_virtualenv: 'Session', monkeypatch): +@pytest.mark.parametrize( + 'tool', [NapariPipInstallerTool, NapariUvInstallerTool] +) +def test_installer_error(qtbot, tool, monkeypatch): + monkeypatch.setattr(NapariInstallerQueue, 'PYPI_INSTALLER_TOOL_CLASS', tool) installer = NapariInstallerQueue() monkeypatch.setattr( - NapariPipInstallerTool, - 'executable', + tool, + 'executable' + if tool == NapariPipInstallerTool + else '_python_executable', lambda *a: 'not-a-real-executable', ) with qtbot.waitSignal(installer.allFinished, timeout=600_000): installer.install( - tool=InstallerTools.PIP, + tool=InstallerTools.PYPI, pkgs=['some-package-that-does-not-exist'], ) @@ -358,11 +392,13 @@ def test_constraints_are_in_sync(): def test_executables(): assert NapariCondaInstallerTool.executable() assert NapariPipInstallerTool.executable() + assert NapariUvInstallerTool.executable() def test_available(): assert str(NapariCondaInstallerTool.available()) assert NapariPipInstallerTool.available() + assert NapariUvInstallerTool.available() def test_unrecognized_tool(): diff --git a/src/napari_plugin_manager/_tests/test_qt_plugin_dialog.py b/src/napari_plugin_manager/_tests/test_qt_plugin_dialog.py index 752bef7..5357be6 100644 --- a/src/napari_plugin_manager/_tests/test_qt_plugin_dialog.py +++ b/src/napari_plugin_manager/_tests/test_qt_plugin_dialog.py @@ -513,12 +513,12 @@ def test_install_pypi_constructor( monkeypatch.setattr( qt_plugin_dialog.PluginListItem, 'get_installer_tool', - lambda self: InstallerTools.PIP, + lambda self: InstallerTools.PYPI, ) monkeypatch.setattr( qt_plugin_dialog.PluginListItem, 'get_installer_source', - lambda self: 'PIP', + lambda self: 'PyPI', ) plugin_dialog.set_prefix(str(tmp_virtualenv)) diff --git a/src/napari_plugin_manager/base_qt_package_installer.py b/src/napari_plugin_manager/base_qt_package_installer.py index 96dcc01..423c10f 100644 --- a/src/napari_plugin_manager/base_qt_package_installer.py +++ b/src/napari_plugin_manager/base_qt_package_installer.py @@ -20,7 +20,7 @@ from functools import lru_cache from logging import getLogger from pathlib import Path -from subprocess import call +from subprocess import run from tempfile import gettempdir from typing import TypedDict @@ -61,7 +61,7 @@ class InstallerTools(StringEnum): "Installer tools selectable by InstallerQueue jobs" CONDA = auto() - PIP = auto() + PYPI = auto() @dataclass(frozen=True) @@ -122,7 +122,10 @@ class PipInstallerTool(AbstractInstallerTool): @classmethod def available(cls): """Check if pip is available.""" - return call([cls.executable(), '-m', 'pip', '--version']) == 0 + process = run( + [cls.executable(), '-m', 'pip', '--version'], capture_output=True + ) + return process.returncode == 0 def arguments(self) -> tuple[str, ...]: """Compose arguments for the pip command.""" @@ -149,7 +152,7 @@ def arguments(self) -> tuple[str, ...]: else: raise ValueError(f"Action '{self.action}' not supported!") - if log.getEffectiveLevel() < 30: # DEBUG and INFOlevel + if log.getEffectiveLevel() < 30: # DEBUG and INFO level args.append('-vvv') if self.prefix is not None: @@ -171,6 +174,79 @@ def _constraints_file(cls) -> str: raise NotImplementedError +class UvInstallerTool(AbstractInstallerTool): + """Uv installer tool for the plugin manager. + + This class is used to install and uninstall packages using uv. + """ + + @classmethod + def executable(cls): + "Path to the executable that will run the task" + if sys.platform == 'win32': + return os.path.join(sys.prefix, 'Scripts', 'uv.exe') + return os.path.join(sys.prefix, 'bin', 'uv') + + @classmethod + def available(cls): + """Check if uv is available.""" + try: + process = run([cls.executable(), '--version'], capture_output=True) + except FileNotFoundError: # pragma: no cover + return False + else: + return process.returncode == 0 + + def arguments(self) -> tuple[str, ...]: + """Compose arguments for the uv pip command.""" + args = ['pip'] + + if self.action == InstallerActions.INSTALL: + args += ['install', '-c', self._constraints_file()] + for origin in self.origins: + args += ['--extra-index-url', origin] + + elif self.action == InstallerActions.UPGRADE: + args += [ + 'install', + '--upgrade', + '-c', + self._constraints_file(), + ] + for origin in self.origins: + args += ['--extra-index-url', origin] + + elif self.action == InstallerActions.UNINSTALL: + args += ['uninstall'] + + else: + raise ValueError(f"Action '{self.action}' not supported!") + + if log.getEffectiveLevel() < 30: # DEBUG and INFO level + args.append('-vvv') + + if self.prefix is not None: + args.extend(['--prefix', str(self.prefix)]) + args.extend(['--python', self._python_executable()]) + + return (*args, *self.pkgs) + + def environment( + self, env: QProcessEnvironment = None + ) -> QProcessEnvironment: + if env is None: + env = QProcessEnvironment.systemEnvironment() + return env + + @classmethod + @lru_cache(maxsize=0) + def _constraints_file(cls) -> str: + raise NotImplementedError + + def _python_executable(self) -> str: + raise NotImplementedError + + class CondaInstallerTool(AbstractInstallerTool): """Conda installer tool for the plugin manager. @@ -199,11 +275,12 @@ def executable(cls): @classmethod def available(cls): """Check if the executable is available by checking if it can output its version.""" - executable = cls.executable() try: - return call([executable, '--version']) == 0 + process = run([cls.executable(), '--version'], capture_output=True) except FileNotFoundError: # pragma: no cover return False + else: + return process.returncode == 0 def arguments(self) -> tuple[str, ...]: """Compose arguments for the conda command.""" @@ -280,7 +357,7 @@ class InstallerQueue(QObject): started = Signal() # classes to manage pip and conda installations - PIP_INSTALLER_TOOL_CLASS = PipInstallerTool + PYPI_INSTALLER_TOOL_CLASS = PipInstallerTool CONDA_INSTALLER_TOOL_CLASS = CondaInstallerTool # This should be set to the name of package that handles plugins # e.g `napari` for napari @@ -534,8 +611,8 @@ def _log(self, msg: str): self._output_widget.append(msg) def _get_tool(self, tool: InstallerTools): - if tool == InstallerTools.PIP: - return self.PIP_INSTALLER_TOOL_CLASS + if tool == InstallerTools.PYPI: + return self.PYPI_INSTALLER_TOOL_CLASS if tool == InstallerTools.CONDA: return self.CONDA_INSTALLER_TOOL_CLASS raise ValueError(f'InstallerTool {tool} not recognized!') diff --git a/src/napari_plugin_manager/base_qt_plugin_dialog.py b/src/napari_plugin_manager/base_qt_plugin_dialog.py index a2c747e..fe15d59 100644 --- a/src/napari_plugin_manager/base_qt_plugin_dialog.py +++ b/src/napari_plugin_manager/base_qt_plugin_dialog.py @@ -659,7 +659,7 @@ def get_installer_tool(self): InstallerTools.CONDA if self.source_choice_dropdown.currentText() == CONDA or is_conda_package(self.name, prefix=self.prefix) - else InstallerTools.PIP + else InstallerTools.PYPI ) @@ -1468,7 +1468,7 @@ def _setup_ui(self): self._action_conda.setCheckable(True) self._action_conda.triggered.connect(self._update_direct_entry_text) - self._action_pypi = QAction(self._trans('pip'), self) + self._action_pypi = QAction(self._trans('PyPI'), self) self._action_pypi.setCheckable(True) self._action_pypi.triggered.connect(self._update_direct_entry_text) @@ -1529,11 +1529,11 @@ def _update_direct_entry_text(self): tool = ( str(InstallerTools.CONDA) if self._action_conda.isChecked() - else str(InstallerTools.PIP) + else str(InstallerTools.PYPI) ) self.direct_entry_edit.setPlaceholderText( self._trans( - "install with '{tool}' by name/url, or drop file...", tool=tool + "install from {tool} by name/url, or drop file...", tool=tool ) ) @@ -1602,7 +1602,7 @@ def _install_packages( tool = ( InstallerTools.CONDA if self._action_conda.isChecked() - else InstallerTools.PIP + else InstallerTools.PYPI ) self.installer.install(tool, packages) diff --git a/src/napari_plugin_manager/qt_package_installer.py b/src/napari_plugin_manager/qt_package_installer.py index 203b81d..75b4098 100644 --- a/src/napari_plugin_manager/qt_package_installer.py +++ b/src/napari_plugin_manager/qt_package_installer.py @@ -24,6 +24,7 @@ CondaInstallerTool, InstallerQueue, PipInstallerTool, + UvInstallerTool, ) @@ -63,6 +64,28 @@ def _constraints_file(cls) -> str: return f.name +class NapariUvInstallerTool(UvInstallerTool): + @staticmethod + def constraints() -> Sequence[str]: + """ + Version constraints to limit unwanted changes in installation. + """ + return [f'napari=={_napari_version}'] + + @classmethod + @lru_cache(maxsize=0) + def _constraints_file(cls) -> str: + with NamedTemporaryFile( + 'w', suffix='-napari-constraints.txt', delete=False + ) as f: + f.write('\n'.join(cls.constraints())) + atexit.register(os.unlink, f.name) + return f.name + + def _python_executable(self) -> str: + return str(_get_python_exe()) + + class NapariCondaInstallerTool(CondaInstallerTool): @staticmethod def constraints() -> Sequence[str]: @@ -81,6 +104,10 @@ def constraints() -> Sequence[str]: class NapariInstallerQueue(InstallerQueue): - PIP_INSTALLER_TOOL_CLASS = NapariPipInstallerTool + PYPI_INSTALLER_TOOL_CLASS = ( + NapariUvInstallerTool + if NapariUvInstallerTool.available() + else PipInstallerTool + ) CONDA_INSTALLER_TOOL_CLASS = NapariCondaInstallerTool BASE_PACKAGE_NAME = 'napari' diff --git a/src/napari_plugin_manager/qt_plugin_dialog.py b/src/napari_plugin_manager/qt_plugin_dialog.py index 2ac0dcc..58b0be1 100644 --- a/src/napari_plugin_manager/qt_plugin_dialog.py +++ b/src/napari_plugin_manager/qt_plugin_dialog.py @@ -121,7 +121,7 @@ def _warn_pypi_install(self): def _action_validation(self, tool, action): global DISMISS_WARN_PYPI_INSTALL_DLG if ( - tool == InstallerTools.PIP + tool == InstallerTools.PYPI and action == InstallerActions.INSTALL and self._warn_pypi_install() and not DISMISS_WARN_PYPI_INSTALL_DLG From bc35f5938aa534983c2843830038c37407221169 Mon Sep 17 00:00:00 2001 From: jaimergp Date: Mon, 11 Aug 2025 11:54:12 +0200 Subject: [PATCH 2/8] pre-commit --- .../_tests/test_installer_process.py | 20 ++++++++++++++----- .../base_qt_plugin_dialog.py | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/napari_plugin_manager/_tests/test_installer_process.py b/src/napari_plugin_manager/_tests/test_installer_process.py index 1957f62..0615766 100644 --- a/src/napari_plugin_manager/_tests/test_installer_process.py +++ b/src/napari_plugin_manager/_tests/test_installer_process.py @@ -68,7 +68,9 @@ def test_pip_installer_tasks( qtbot, tool, tmp_virtualenv: 'Session', monkeypatch, caplog ): caplog.set_level(logging.DEBUG, logger=bqpi.__name__) - monkeypatch.setattr(NapariInstallerQueue, 'PYPI_INSTALLER_TOOL_CLASS', tool) + monkeypatch.setattr( + NapariInstallerQueue, 'PYPI_INSTALLER_TOOL_CLASS', tool + ) installer = NapariInstallerQueue() monkeypatch.setattr( tool, @@ -154,7 +156,9 @@ def test_pip_installer_tasks( def test_pip_installer_invalid_action( tool, tmp_virtualenv: 'Session', monkeypatch ): - monkeypatch.setattr(NapariInstallerQueue, 'PYPI_INSTALLER_TOOL_CLASS', tool) + monkeypatch.setattr( + NapariInstallerQueue, 'PYPI_INSTALLER_TOOL_CLASS', tool + ) monkeypatch.setattr( tool, 'executable' @@ -184,7 +188,9 @@ def test_pip_installer_invalid_action( def test_installer_failures( tool, qtbot, tmp_virtualenv: 'Session', monkeypatch ): - monkeypatch.setattr(NapariInstallerQueue, 'PYPI_INSTALLER_TOOL_CLASS', tool) + monkeypatch.setattr( + NapariInstallerQueue, 'PYPI_INSTALLER_TOOL_CLASS', tool + ) installer = NapariInstallerQueue() monkeypatch.setattr( tool, @@ -234,7 +240,9 @@ def test_installer_failures( 'tool', [NapariPipInstallerTool, NapariUvInstallerTool] ) def test_cancel_incorrect_job_id(tool, qtbot, monkeypatch): - monkeypatch.setattr(NapariInstallerQueue, 'PYPI_INSTALLER_TOOL_CLASS', tool) + monkeypatch.setattr( + NapariInstallerQueue, 'PYPI_INSTALLER_TOOL_CLASS', tool + ) installer = NapariInstallerQueue() with qtbot.waitSignal(installer.allFinished, timeout=30_000): job_id = installer.install( @@ -338,7 +346,9 @@ def test_conda_installer(qtbot, caplog, monkeypatch, tmp_conda_env: Path): 'tool', [NapariPipInstallerTool, NapariUvInstallerTool] ) def test_installer_error(qtbot, tool, monkeypatch): - monkeypatch.setattr(NapariInstallerQueue, 'PYPI_INSTALLER_TOOL_CLASS', tool) + monkeypatch.setattr( + NapariInstallerQueue, 'PYPI_INSTALLER_TOOL_CLASS', tool + ) installer = NapariInstallerQueue() monkeypatch.setattr( tool, diff --git a/src/napari_plugin_manager/base_qt_plugin_dialog.py b/src/napari_plugin_manager/base_qt_plugin_dialog.py index fe15d59..084f4d5 100644 --- a/src/napari_plugin_manager/base_qt_plugin_dialog.py +++ b/src/napari_plugin_manager/base_qt_plugin_dialog.py @@ -1533,7 +1533,7 @@ def _update_direct_entry_text(self): ) self.direct_entry_edit.setPlaceholderText( self._trans( - "install from {tool} by name/url, or drop file...", tool=tool + 'install from {tool} by name/url, or drop file...', tool=tool ) ) From ab0e8e1dd073cc4647d47686dd7e5613b4dd5194 Mon Sep 17 00:00:00 2001 From: jaimergp Date: Fri, 29 Aug 2025 16:20:00 +0200 Subject: [PATCH 3/8] Apply suggestions from code review Co-authored-by: Grzegorz Bokota --- src/napari_plugin_manager/base_qt_package_installer.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/napari_plugin_manager/base_qt_package_installer.py b/src/napari_plugin_manager/base_qt_package_installer.py index 423c10f..a880489 100644 --- a/src/napari_plugin_manager/base_qt_package_installer.py +++ b/src/napari_plugin_manager/base_qt_package_installer.py @@ -120,7 +120,7 @@ class PipInstallerTool(AbstractInstallerTool): """ @classmethod - def available(cls): + def available(cls) -> bool: """Check if pip is available.""" process = run( [cls.executable(), '-m', 'pip', '--version'], capture_output=True @@ -181,14 +181,14 @@ class UvInstallerTool(AbstractInstallerTool): """ @classmethod - def executable(cls): + def executable(cls) -> str: "Path to the executable that will run the task" if sys.platform == 'win32': return os.path.join(sys.prefix, 'Scripts', 'uv.exe') return os.path.join(sys.prefix, 'bin', 'uv') @classmethod - def available(cls): + def available(cls) -> bool: """Check if uv is available.""" try: process = run([cls.executable(), '--version'], capture_output=True) From c276e3143312f7f5fd4bc4575ceaf2135ded4245 Mon Sep 17 00:00:00 2001 From: jaimergp Date: Fri, 29 Aug 2025 16:26:42 +0200 Subject: [PATCH 4/8] default to uv --- src/napari_plugin_manager/qt_package_installer.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/napari_plugin_manager/qt_package_installer.py b/src/napari_plugin_manager/qt_package_installer.py index 75b4098..a2001ed 100644 --- a/src/napari_plugin_manager/qt_package_installer.py +++ b/src/napari_plugin_manager/qt_package_installer.py @@ -104,10 +104,6 @@ def constraints() -> Sequence[str]: class NapariInstallerQueue(InstallerQueue): - PYPI_INSTALLER_TOOL_CLASS = ( - NapariUvInstallerTool - if NapariUvInstallerTool.available() - else PipInstallerTool - ) + PYPI_INSTALLER_TOOL_CLASS = NapariUvInstallerTool CONDA_INSTALLER_TOOL_CLASS = NapariCondaInstallerTool BASE_PACKAGE_NAME = 'napari' From 7854098f0bfb492cd98859bd65638cb63ce99da1 Mon Sep 17 00:00:00 2001 From: jaimergp Date: Sat, 30 Aug 2025 14:07:06 +0200 Subject: [PATCH 5/8] adjust return types --- src/napari_plugin_manager/base_qt_package_installer.py | 4 ++-- src/napari_plugin_manager/qt_package_installer.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/napari_plugin_manager/base_qt_package_installer.py b/src/napari_plugin_manager/base_qt_package_installer.py index 5b34006..5cb4e47 100644 --- a/src/napari_plugin_manager/base_qt_package_installer.py +++ b/src/napari_plugin_manager/base_qt_package_installer.py @@ -197,7 +197,7 @@ def available(cls) -> bool: else: return process.returncode == 0 - def arguments(self) -> tuple[str, ...]: + def arguments(self) -> list[str]: """Compose arguments for the uv pip command.""" args = ['pip'] @@ -229,7 +229,7 @@ def arguments(self) -> tuple[str, ...]: args.extend(['--prefix', str(self.prefix)]) args.extend(['--python', self._python_executable()]) - return (*args, *self.pkgs) + return [*args, *self.pkgs] def environment( self, env: QProcessEnvironment = None diff --git a/src/napari_plugin_manager/qt_package_installer.py b/src/napari_plugin_manager/qt_package_installer.py index d6f2de1..ed4ef56 100644 --- a/src/napari_plugin_manager/qt_package_installer.py +++ b/src/napari_plugin_manager/qt_package_installer.py @@ -65,7 +65,7 @@ def _constraints_file(cls) -> str: class NapariUvInstallerTool(UvInstallerTool): @staticmethod - def constraints() -> Sequence[str]: + def constraints() -> list[str]: """ Version constraints to limit unwanted changes in installation. """ From 4bef3e89a762d75d109bf4edbc99f176db193774 Mon Sep 17 00:00:00 2001 From: jaimergp Date: Mon, 1 Sep 2025 12:33:44 +0200 Subject: [PATCH 6/8] use --upgrade-package in uv --- src/napari_plugin_manager/base_qt_package_installer.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/napari_plugin_manager/base_qt_package_installer.py b/src/napari_plugin_manager/base_qt_package_installer.py index 5cb4e47..325c1b2 100644 --- a/src/napari_plugin_manager/base_qt_package_installer.py +++ b/src/napari_plugin_manager/base_qt_package_installer.py @@ -207,15 +207,11 @@ def arguments(self) -> list[str]: args += ['--extra-index-url', origin] elif self.action == InstallerActions.UPGRADE: - args += [ - 'install', - '--upgrade', - '-c', - self._constraints_file(), - ] + args += ['install', '-c', self._constraints_file()] for origin in self.origins: args += ['--extra-index-url', origin] - + for pkg in self.pkgs: + args.append(f'--upgrade-package={pkg}') elif self.action == InstallerActions.UNINSTALL: args += ['uninstall'] From de2bc7cf88bee6517e077be54dd20a189fd58bda Mon Sep 17 00:00:00 2001 From: jaimergp Date: Thu, 2 Oct 2025 17:27:09 +0200 Subject: [PATCH 7/8] Only use uv backend if uv is in PATH --- pyproject.toml | 4 +++- src/napari_plugin_manager/qt_package_installer.py | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 405c356..85b9d74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,6 @@ dependencies = [ "superqt", "pip", "packaging", - "uv", ] dynamic = [ "version" @@ -66,6 +65,9 @@ dev = [ "PyQt6", "pre-commit", ] +uv = [ + "uv", +] testing = [ "coverage", diff --git a/src/napari_plugin_manager/qt_package_installer.py b/src/napari_plugin_manager/qt_package_installer.py index ed4ef56..b0f91a5 100644 --- a/src/napari_plugin_manager/qt_package_installer.py +++ b/src/napari_plugin_manager/qt_package_installer.py @@ -103,6 +103,10 @@ def constraints() -> list[str]: class NapariInstallerQueue(InstallerQueue): - PYPI_INSTALLER_TOOL_CLASS = NapariUvInstallerTool + PYPI_INSTALLER_TOOL_CLASS = ( + NapariUvInstallerTool + if NapariUvInstallerTool.available() + else NapariPipInstallerTool + ) CONDA_INSTALLER_TOOL_CLASS = NapariCondaInstallerTool BASE_PACKAGE_NAME = 'napari' From 0e44be162eba78317ebef2b4ff7f7f0303a572fe Mon Sep 17 00:00:00 2001 From: jaimergp Date: Thu, 2 Oct 2025 17:36:06 +0200 Subject: [PATCH 8/8] Prefer `uv` installed in the env --- src/napari_plugin_manager/base_qt_package_installer.py | 8 ++++++-- tox.ini | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/napari_plugin_manager/base_qt_package_installer.py b/src/napari_plugin_manager/base_qt_package_installer.py index 325c1b2..f2d2206 100644 --- a/src/napari_plugin_manager/base_qt_package_installer.py +++ b/src/napari_plugin_manager/base_qt_package_installer.py @@ -184,8 +184,12 @@ class UvInstallerTool(AbstractInstallerTool): def executable(cls) -> str: "Path to the executable that will run the task" if sys.platform == 'win32': - return os.path.join(sys.prefix, 'Scripts', 'uv.exe') - return os.path.join(sys.prefix, 'bin', 'uv') + path = os.path.join(sys.prefix, 'Scripts', 'uv.exe') + else: + path = os.path.join(sys.prefix, 'bin', 'uv') + if os.path.isfile(path): + return path + return 'uv' @classmethod def available(cls) -> bool: diff --git a/tox.ini b/tox.ini index 199d2b4..7fe7a0d 100644 --- a/tox.ini +++ b/tox.ini @@ -35,7 +35,7 @@ setenv = deps = napari_repo: git+https://github.com/napari/napari.git -extras = testing +extras = testing,uv commands = coverage run --parallel-mode -m pytest -v --color=yes # Conditional PIP dependencies based on environment variables