Skip to content
Draft
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
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",
"py-rattler",
]
dynamic = [
"version"
Expand Down
196 changes: 196 additions & 0 deletions src/napari_plugin_manager/_rattler_installer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
"""
An internal CLI interface to py-rattler installation routines.
"""

from __future__ import annotations

import argparse
import asyncio
import json
import logging
import sys
from datetime import timedelta
from pathlib import Path
from typing import TYPE_CHECKING

import rattler

if TYPE_CHECKING:
from collections.abc import Iterable
from typing import Literal

log = logging.getLogger(__name__)


def cli() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog='rattler-for-napari')
p.add_argument(
'specs',
nargs='+',
help='Packages to handle',
)
p.add_argument(
'--action',
choices=('install', 'remove', 'update', 'uninstall', 'upgrade'),
help='Action to perform: install (also creates), remove/uninstall, update/upgrade.',
)
p.add_argument(
'-p',
'--prefix',
type=Path,
required=True,
help='Target prefix',
)
p.add_argument(
'--constraint',
dest='constraints',
action='append',
help='Solver constraints, as a spec',
)
p.add_argument(
'-c',
'--channel',
dest='channels',
action='append',
help='Conda channel(s) to pull from.',
)
p.add_argument(
'-v',
'--verbose',
action='store_true',
help='Increase amount of output',
)
p.add_argument(
'--dry-run',
action='store_true',
help='Only solve environment, do not modify.',
)
return p


def _installed(prefix: Path) -> list[rattler.prefix.PrefixRecord]:
if not (prefix / 'conda-meta' / 'history').is_file():
return []
return [
rattler.prefix.PrefixRecord.from_path(path)
for path in prefix.glob('conda-meta/*-*-*.json')
]


async def solve_records(
action: Literal['install', 'update', 'upgrade', 'remove', 'uninstall'],
specs: Iterable[rattler.MatchSpec],
channels: Iterable[str] = (),
constraints: Iterable[rattler.MatchSpec | str] = (),
installed: Iterable[rattler.prefix.PrefixRecord] = (),
) -> tuple[list[rattler.RepoDataRecord], list[rattler.MatchSpec]]:
specs = list(specs)
names = {spec.name for spec in specs}
installed = list(installed)
locked = installed.copy()
channels = channels or ('conda-forge',)
constraints = constraints or []
if action in ('remove', 'uninstall'):
specs = [
record.requested_spec
for record in installed
if record.requested_spec and record.name not in names
]
constraints.extend([f'{name.source}<0' for name in names])
elif action in ('install', 'update', 'upgrade'):
for record in installed:
if record.requested_spec and record.name not in names:
specs.append(record.requested_spec)
if action in ('update', 'upgrade'):
locked = [record for record in locked if record.name not in names]
else:
raise ValueError("'action' must be 'install', 'update', or 'remove'.")
for channel in channels:
log.info('Channel: %s', channel)
for spec in specs:
log.info('Spec: %s', spec)
for constraint in constraints:
log.info('Constraint: %s', constraint)

return await rattler.solve(
channels,
specs,
virtual_packages=rattler.VirtualPackage.detect(),
timeout=timedelta(seconds=90),
constraints=constraints,
locked_packages=locked,
), specs


def validate_solution(records: Iterable[rattler.RepoDataRecord]):
"""
Here we can apply some logic to make sure the application is ok with it
(e.g. no napari updates)
"""


async def main(argv: Iterable[str] | None = None) -> int:
args = cli().parse_args(argv)
logging.basicConfig(
level=logging.INFO if args.verbose else logging.WARNING
)

specs = [rattler.MatchSpec(spec) for spec in args.specs]
installed = _installed(args.prefix)
installed_names = {record.name.normalized for record in installed}
if args.action in ('remove', 'update'):
notpresent = []
for spec in specs:
if spec.name.normalized not in installed_names:
notpresent.append(str(spec))
if notpresent:
raise argparse.ArgumentError(
None,
message='Some packages are not present in '
f'the environment and cannot be {args.action}d: {", ".join(notpresent)}',
)
records, requested = await solve_records(
args.action,
specs,
args.channels,
args.constraints,
installed=installed,
)
validate_solution(records)
for record in records:
log.info('Solution: %s', record)

if args.dry_run:
log.info('Dry run. Exiting.')
return 0

log.info('Applying solution to %s', args.prefix.resolve())
await rattler.install(
records=records,
target_prefix=args.prefix,
show_progress=args.verbose,
)

# Patch 'requested_spec' in 'conda-meta/*.json'
# Workaround for https://github.com/conda/rattler/issues/1595
for spec in requested:
for conda_meta_json in args.prefix.glob(
f'conda-meta/{spec.name.normalized}-*-*.json'
):
name, _, _ = conda_meta_json.stem.rsplit('-', 2)
if name.lower() in (
spec.name.source.lower(),
spec.name.normalized,
):
data = json.loads(conda_meta_json.read_text())
data['requested_spec'] = str(spec)
conda_meta_json.write_text(json.dumps(data, indent=2))
break

log.info('Done.')

return 0


if __name__ == '__main__':
sys.exit(asyncio.run(main()))
30 changes: 11 additions & 19 deletions src/napari_plugin_manager/_tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
import pytest
from qtpy.QtWidgets import QDialog, QInputDialog, QMessageBox

from napari_plugin_manager.qt_package_installer import CondaInstallerTool


@pytest.fixture(autouse=True)
def _block_message_box(monkeypatch, request):
Expand Down Expand Up @@ -37,27 +35,21 @@ def tmp_virtualenv(tmp_path) -> 'Session':

@pytest.fixture
def tmp_conda_env(tmp_path):
import subprocess
import asyncio

from napari_plugin_manager._rattler_installer import main

try:
subprocess.check_output(
asyncio.run(
main(
[
CondaInstallerTool.executable(),
'create',
'-yq',
'-p',
'--action',
'install',
'--prefix',
str(tmp_path),
'--override-channels',
'-c',
'conda-forge',
'--verbose',
f'python={sys.version_info.major}.{sys.version_info.minor}',
],
stderr=subprocess.STDOUT,
text=True,
timeout=300,
]
)
except subprocess.CalledProcessError as exc:
print(exc.output)
raise
)

return tmp_path
48 changes: 32 additions & 16 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,
NapariRattlerInstallerTool,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -217,10 +218,17 @@ def test_cancel_incorrect_job_id(qtbot, tmp_virtualenv: 'Session'):
installer.cancel(job_id + 1)


@pytest.mark.skipif(
not NapariCondaInstallerTool.available(), reason='Conda is not available.'
@pytest.mark.parametrize(
'tool', [InstallerTools.CONDA, InstallerTools.RATTLER]
)
def test_conda_installer(qtbot, caplog, monkeypatch, tmp_conda_env: Path):
def test_conda_installer(
tool, qtbot, caplog, monkeypatch, tmp_conda_env: Path
):
if (
tool == InstallerTools.CONDA
and not NapariCondaInstallerTool.available()
):
pytest.skip('Conda not available')
if sys.platform == 'darwin':
# check handled for `PYTHONEXECUTABLE` env definition on macOS
monkeypatch.setenv('PYTHONEXECUTABLE', sys.executable)
Expand All @@ -232,7 +240,7 @@ def test_conda_installer(qtbot, caplog, monkeypatch, tmp_conda_env: Path):

with qtbot.waitSignal(installer.allFinished, timeout=600_000):
installer.install(
tool=InstallerTools.CONDA,
tool=tool,
pkgs=['typing-extensions'],
prefix=tmp_conda_env,
)
Expand All @@ -242,7 +250,7 @@ def test_conda_installer(qtbot, caplog, monkeypatch, tmp_conda_env: Path):

with qtbot.waitSignal(installer.allFinished, timeout=600_000):
installer.uninstall(
tool=InstallerTools.CONDA,
tool=tool,
pkgs=['typing-extensions'],
prefix=tmp_conda_env,
)
Expand All @@ -253,12 +261,12 @@ def test_conda_installer(qtbot, caplog, monkeypatch, tmp_conda_env: Path):
# Check canceling all works
with qtbot.waitSignal(installer.allFinished, timeout=600_000):
installer.install(
tool=InstallerTools.CONDA,
tool=tool,
pkgs=['typing-extensions'],
prefix=tmp_conda_env,
)
installer.install(
tool=InstallerTools.CONDA,
tool=tool,
pkgs=['packaging'],
prefix=tmp_conda_env,
)
Expand All @@ -272,12 +280,12 @@ def test_conda_installer(qtbot, caplog, monkeypatch, tmp_conda_env: Path):
# Check canceling current job works (1st in queue)
with qtbot.waitSignal(installer.allFinished, timeout=600_000):
job_id_1 = installer.install(
tool=InstallerTools.CONDA,
tool=tool,
pkgs=['typing-extensions'],
prefix=tmp_conda_env,
)
job_id_2 = installer.install(
tool=InstallerTools.CONDA,
tool=tool,
pkgs=['packaging'],
prefix=tmp_conda_env,
)
Expand All @@ -290,12 +298,12 @@ def test_conda_installer(qtbot, caplog, monkeypatch, tmp_conda_env: Path):
# Check canceling queued job works (somewhere besides 1st position in queue)
with qtbot.waitSignal(installer.allFinished, timeout=600_000):
job_id_1 = installer.install(
tool=InstallerTools.CONDA,
tool=tool,
pkgs=['typing-extensions'],
prefix=tmp_conda_env,
)
job_id_2 = installer.install(
tool=InstallerTools.CONDA,
tool=tool,
pkgs=['packaging'],
prefix=tmp_conda_env,
)
Expand All @@ -320,20 +328,26 @@ def test_installer_error(qtbot, tmp_virtualenv: 'Session', monkeypatch):
)


@pytest.mark.skipif(
not NapariCondaInstallerTool.available(), reason='Conda is not available.'
@pytest.mark.parametrize(
'tool', [InstallerTools.CONDA, InstallerTools.RATTLER]
)
def test_conda_installer_wait_for_finished(qtbot, tmp_conda_env: Path):
def test_conda_installer_wait_for_finished(tool, qtbot, tmp_conda_env: Path):
if (
tool == InstallerTools.CONDA
and not NapariCondaInstallerTool.available()
):
pytest.skip('Conda not available')

installer = NapariInstallerQueue()

with qtbot.waitSignal(installer.allFinished, timeout=600_000):
installer.install(
tool=InstallerTools.CONDA,
tool=tool,
pkgs=['requests'],
prefix=tmp_conda_env,
)
installer.install(
tool=InstallerTools.CONDA,
tool=tool,
pkgs=['packaging'],
prefix=tmp_conda_env,
)
Expand All @@ -358,11 +372,13 @@ def test_constraints_are_in_sync():
def test_executables():
assert NapariCondaInstallerTool.executable()
assert NapariPipInstallerTool.executable()
assert NapariRattlerInstallerTool.executable()


def test_available():
assert str(NapariCondaInstallerTool.available())
assert NapariPipInstallerTool.available()
assert NapariPipInstallerTool.available()


def test_unrecognized_tool():
Expand Down
Loading
Loading