Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ dev = [
"PyQt6",
"pre-commit",
]
uv = [
"uv",
]

testing = [
"coverage",
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',),
)
Comment on lines +82 to +87
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is this if is needed? Why we cannot uify this?

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',
Comment on lines +356 to 358
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?

)
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
87 changes: 82 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,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) -> str:
"Path to the executable that will run the task"
if sys.platform == 'win32':
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:
"""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 +275,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
29 changes: 28 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,10 @@ def constraints() -> list[str]:


class NapariInstallerQueue(InstallerQueue):
PYPI_INSTALLER_TOOL_CLASS = NapariPipInstallerTool
PYPI_INSTALLER_TOOL_CLASS = (
NapariUvInstallerTool
if NapariUvInstallerTool.available()
else NapariPipInstallerTool
)
CONDA_INSTALLER_TOOL_CLASS = NapariCondaInstallerTool
BASE_PACKAGE_NAME = 'napari'
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ setenv =

deps =
napari_repo: git+https://github.com/napari/napari.git
extras = testing
Copy link
Contributor

Choose a reason for hiding this comment

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

As there is no skip for uv tests I prefer to add uv to testing extras.

Using napari-plugin-manager[uv] in testing extras.

extras = testing,uv
commands = coverage run --parallel-mode -m pytest -v --color=yes

# Conditional PIP dependencies based on environment variables
Expand Down
Loading