Skip to content
Merged
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ dependencies = [
"superqt",
"pip",
"packaging",
"uv",
]
dynamic = [
"version"
Expand Down
84 changes: 65 additions & 19 deletions src/napari_plugin_manager/_tests/test_installer_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
NapariCondaInstallerTool,
NapariInstallerQueue,
NapariPipInstallerTool,
NapariUvInstallerTool,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -60,21 +61,30 @@ 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__)
installer = NapariInstallerQueue()
monkeypatch.setattr(
NapariPipInstallerTool,
'executable',
lambda *a: tmp_virtualenv.creator.exe,
NapariInstallerQueue, 'PYPI_INSTALLER_TOOL_CLASS', tool
)
installer = NapariInstallerQueue()
monkeypatch.setattr(
NapariPipInstallerTool,
'origins',
('https://pypi.org/simple',),
tool,
'executable'
if tool == NapariPipInstallerTool
else '_python_executable',
lambda *a: tmp_virtualenv.creator.exe,
)
if tool == NapariPipInstallerTool:
monkeypatch.setattr(
NapariPipInstallerTool,
'origins',
('https://pypi.org/simple',),
)
with qtbot.waitSignal(installer.allFinished, timeout=30_000):
installer.install(
tool=InstallerTools.PYPI,
Expand Down Expand Up @@ -140,13 +150,23 @@ def test_pip_installer_tasks(
)


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.PYPI,
Expand All @@ -162,11 +182,21 @@ 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,
)

Expand Down Expand Up @@ -206,7 +236,13 @@ 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(
Expand Down Expand Up @@ -306,11 +342,19 @@ 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',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why we can not unify this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Context: Tool.executable is the binary that will be run by QProcess.

In pip, the executable is the Python interpreter. In uv, the executable is uv itself, but we also need to pass the installed Python interpreter to uv so it uses that one instead of their own build of Python standalone.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still cannot understand why we cannot patch just executable in uv installers?

Also, this patch is repeated multiple times in this file. Maybe move this to fixture?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case you have commented on, yes, we can patch executable for both because we simply want to create an error. In the other ones, it's tmp_virtualenv.creator.exe we need to set to, so it uses the testing environment and not the actual installation. I'm not sure we want to introduce a fixture for just three cases, but let me give it a try.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check cbacb0c

)
with qtbot.waitSignal(installer.allFinished, timeout=600_000):
Expand Down Expand Up @@ -358,11 +402,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():
Expand Down
2 changes: 1 addition & 1 deletion src/napari_plugin_manager/_tests/test_qt_plugin_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ def test_install_pypi_constructor(
monkeypatch.setattr(
qt_plugin_dialog.PluginListItem,
'get_installer_source',
lambda self: 'PIP',
lambda self: 'PyPI',
)

plugin_dialog.set_prefix(str(tmp_virtualenv))
Expand Down
83 changes: 78 additions & 5 deletions src/napari_plugin_manager/base_qt_package_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -122,7 +122,10 @@ class PipInstallerTool(AbstractInstallerTool):
@classmethod
def available(cls) -> bool:
"""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) -> list[str]:
"""Compose arguments for the pip command."""
Expand All @@ -149,7 +152,7 @@ def arguments(self) -> list[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:
Expand All @@ -171,6 +174,75 @@ 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) -> 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) -> bool:
"""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) -> list[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', '-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']

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.

Expand Down Expand Up @@ -199,11 +271,12 @@ def executable(cls) -> str:
@classmethod
def available(cls) -> bool:
"""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) -> list[str]:
"""Compose arguments for the conda command."""
Expand Down
25 changes: 24 additions & 1 deletion src/napari_plugin_manager/qt_package_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
CondaInstallerTool,
InstallerQueue,
PipInstallerTool,
UvInstallerTool,
)


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


class NapariUvInstallerTool(UvInstallerTool):
@staticmethod
def constraints() -> list[str]:
"""
Version constraints to limit unwanted changes in installation.
"""
return [f'napari=={_napari_version}']
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm. So the only risk for now is if the environment has Numba installed and the plugin needs a NumPy newer than the installed one. But it is a problem as well for pip.


@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() -> list[str]:
Expand All @@ -80,6 +103,6 @@ def constraints() -> list[str]:


class NapariInstallerQueue(InstallerQueue):
PYPI_INSTALLER_TOOL_CLASS = NapariPipInstallerTool
PYPI_INSTALLER_TOOL_CLASS = NapariUvInstallerTool
CONDA_INSTALLER_TOOL_CLASS = NapariCondaInstallerTool
BASE_PACKAGE_NAME = 'napari'
Loading