Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH Add support for reinstalling packages #64

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added `verbose` parameter to micropip.install and micropip.uninstall
[#60](https://github.com/pyodide/micropip/pull/60)

- Added `reinstall` parameter to micropip.install to allow reinstalling
a package that is already installed
[#64](https://github.com/pyodide/micropip/pull/64)

### Fixed

- Fixed `micropip.add_mock_package` to work with Pyodide>=0.23.0
Expand Down
30 changes: 24 additions & 6 deletions micropip/_commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
from packaging.markers import default_environment

from .._compat import loadPackage, to_js
from .._uninstall import uninstall_distributions
from ..constants import FAQ_URLS
from ..logging import setup_logging
from ..logging import indent_log, setup_logging
from ..transaction import Transaction


Expand All @@ -16,6 +17,7 @@ async def install(
deps: bool = True,
credentials: str | None = None,
pre: bool = False,
reinstall: bool = False,
*,
verbose: bool | int = False,
) -> None:
Expand Down Expand Up @@ -86,6 +88,10 @@ async def install(
If ``True``, include pre-release and development versions. By default,
micropip only finds stable versions.

reinstall :

If ``True``, reinstall packages if they are already installed.

verbose :
Print more information about the process.
By default, micropip is silent. Setting ``verbose=True`` will print
Expand Down Expand Up @@ -115,6 +121,7 @@ async def install(
keep_going=keep_going,
deps=deps,
pre=pre,
reinstall=reinstall,
fetch_kwargs=fetch_kwargs,
verbose=verbose,
)
Expand All @@ -127,12 +134,23 @@ async def install(
f"See: {FAQ_URLS['cant_find_wheel']}\n"
)

package_names = [pkg.name for pkg in transaction.pyodide_packages] + [
pkg.name for pkg in transaction.wheels
]
# uninstall packages that are installed
packages_all = set([pkg.name for pkg in transaction.wheels]) | set(
[pkg.name for pkg in transaction.pyodide_packages]
)

distributions = []
for pkg_name in packages_all:
try:
distributions.append(importlib.metadata.distribution(pkg_name))
except importlib.metadata.PackageNotFoundError:
pass

with indent_log():
uninstall_distributions(distributions)

if package_names:
logger.info("Installing collected packages: " + ", ".join(package_names))
if packages_all:
logger.info("Installing collected packages: " + ", ".join(packages_all))

wheel_promises = []
# Install built-in packages
Expand Down
56 changes: 2 additions & 54 deletions micropip/_commands/uninstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
import importlib.metadata
from importlib.metadata import Distribution

from .._compat import loadedPackages
from .._utils import get_files_in_distribution, get_root
from .._uninstall import uninstall_distributions
from ..logging import setup_logging


Expand Down Expand Up @@ -41,57 +40,6 @@ def uninstall(packages: str | list[str], *, verbose: bool | int = False) -> None
except importlib.metadata.PackageNotFoundError:
logger.warning(f"Skipping '{package}' as it is not installed.")

for dist in distributions:
# Note: this value needs to be retrieved before removing files, as
# dist.name uses metadata file to get the name
name = dist.name
version = dist.version

logger.info(f"Found existing installation: {name} {version}")

root = get_root(dist)
files = get_files_in_distribution(dist)
directories = set()

for file in files:
if not file.is_file():
if not file.is_relative_to(root):
# This file is not in the site-packages directory. Probably one of:
# - data_files
# - scripts
# - entry_points
# Since we don't support these, we can ignore them (except for data_files (TODO))
continue

logger.warning(
f"A file '{file}' listed in the metadata of '{name}' does not exist.",
)

continue

file.unlink()

if file.parent != root:
directories.add(file.parent)

# Remove directories in reverse hierarchical order
for directory in sorted(directories, key=lambda x: len(x.parts), reverse=True):
try:
directory.rmdir()
except OSError:
logger.warning(
f"A directory '{directory}' is not empty after uninstallation of '{name}'. "
"This might cause problems when installing a new version of the package. ",
)

if hasattr(loadedPackages, name):
delattr(loadedPackages, name)
else:
# This should not happen, but just in case
logger.warning(
f"a package '{name}' was not found in loadedPackages.",
)

logger.info(f"Successfully uninstalled {name}-{version}")
uninstall_distributions(distributions)

importlib.invalidate_caches()
78 changes: 78 additions & 0 deletions micropip/_uninstall.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import logging
from collections.abc import Iterable
from importlib.metadata import Distribution

from ._compat import loadedPackages
from ._utils import get_files_in_distribution, get_root

logger = logging.getLogger("micropip")


def uninstall_distributions(distributions: Iterable[Distribution]) -> None:
"""Uninstall the given package distributions.

This function does not do any checks, so make sure that the distributions
are installed and that they are installed using a wheel file, i.e. packages
that have distribution metadata.

This function also does not invalidate the import cache, so make sure to
call `importlib.invalidate_caches()` after calling this function.

Parameters
----------
distributions
Package distributions to uninstall.
"""

for dist in distributions:
# Note: this value needs to be retrieved before removing files, as
# dist.name uses metadata file to get the name
name = dist.name
version = dist.version

logger.info(f"Found existing installation: {name} {version}")

root = get_root(dist)
files = get_files_in_distribution(dist)
directories = set()

for file in files:
if not file.is_file():
if not file.is_relative_to(root):
# This file is not in the site-packages directory. Probably one of:
# - data_files
# - scripts
# - entry_points
# Since we don't support these, we can ignore them (except for data_files (TODO))
continue

logger.warning(
f"A file '{file}' listed in the metadata of '{name}' does not exist.",
)

continue

file.unlink()

if file.parent != root:
directories.add(file.parent)

# Remove directories in reverse hierarchical order
for directory in sorted(directories, key=lambda x: len(x.parts), reverse=True):
try:
directory.rmdir()
except OSError:
logger.warning(
f"A directory '{directory}' is not empty after uninstallation of '{name}'. "
"This might cause problems when installing a new version of the package. ",
)

if hasattr(loadedPackages, name):
delattr(loadedPackages, name)
else:
# This should not happen, but just in case
logger.warning(
f"a package '{name}' was not found in loadedPackages.",
)

logger.info(f"Successfully uninstalled {name}-{version}")
42 changes: 34 additions & 8 deletions micropip/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ class Transaction:
keep_going: bool
deps: bool
pre: bool
reinstall: bool
fetch_kwargs: dict[str, str]

locked: dict[str, PackageMetadata] = field(default_factory=dict)
Expand Down Expand Up @@ -265,7 +266,21 @@ async def add_requirement(self, req: str | Requirement) -> None:

await self.add_wheel(wheel, extras=set(), specifier="")

def check_version_satisfied(self, req: Requirement) -> tuple[bool, str]:
def check_version_satisfied(
self, req: Requirement, *, allow_reinstall: bool = False
) -> tuple[bool, str]:
"""
Check if the installed version of a package satisfies the requirement.
Returns True if the requirement is satisfied, False otherwise.

Parameters
----------
req
The requirement to check.
allow_reinstall
If False, this function will raise exception if the package is already installed
and the installed version does not satisfy the requirement.
"""
ver = None
try:
ver = importlib.metadata.version(req.name)
Expand All @@ -281,9 +296,16 @@ def check_version_satisfied(self, req: Requirement) -> tuple[bool, str]:
# installed version matches, nothing to do
return True, ver

raise ValueError(
f"Requested '{req}', " f"but {req.name}=={ver} is already installed"
)
if allow_reinstall:
return False, ""
else:
raise ValueError(
f"Requested '{req}', "
f"but {req.name}=={ver} is already installed. "
"If you want to reinstall the package with a different version, "
"use micropip.install(..., reinstall=True) to force reinstall, "
"or micropip.uninstall(...) to uninstall the package first."
)

async def add_requirement_inner(
self,
Expand Down Expand Up @@ -336,7 +358,9 @@ def eval_marker(e: dict[str, str]) -> bool:
# Is some version of this package is already installed?
req.name = canonicalize_name(req.name)

satisfied, ver = self.check_version_satisfied(req)
satisfied, ver = self.check_version_satisfied(
req, allow_reinstall=self.reinstall
)
if satisfied:
logger.info(f"Requirement already satisfied: {req} ({ver})")
return
Expand All @@ -363,10 +387,12 @@ def eval_marker(e: dict[str, str]) -> bool:
else:
return

# Maybe while we were downloading pypi_json some other branch
# installed the wheel?
satisfied, ver = self.check_version_satisfied(req)
satisfied, ver = self.check_version_satisfied(
req, allow_reinstall=self.reinstall
)
if satisfied:
# Maybe while we were downloading pypi_json some other branch
# installed the wheel?
logger.info(f"Requirement already satisfied: {req} ({ver})")
return

Expand Down
10 changes: 10 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,20 @@ def _mock_importlib_version(name: str) -> str:
def _mock_importlib_distributions():
return (Distribution.at(p) for p in wheel_base.glob("*.dist-info")) # type: ignore[union-attr]

def _mock_importlib_distribution(name: str) -> Distribution:
for dist in _mock_importlib_distributions():
if dist.name == name:
return dist

raise PackageNotFoundError(name)

monkeypatch.setattr(importlib.metadata, "version", _mock_importlib_version)
monkeypatch.setattr(
importlib.metadata, "distributions", _mock_importlib_distributions
)
monkeypatch.setattr(
importlib.metadata, "distribution", _mock_importlib_distribution
)


class Wildcard:
Expand Down
28 changes: 28 additions & 0 deletions tests/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,34 @@ async def run_test(selenium, url, wheel_name):
run_test(selenium_standalone_micropip, url, SNOWBALL_WHEEL)


@pytest.mark.asyncio
async def test_reinstall_different_version(
mock_fetch: mock_fetch_cls,
mock_importlib,
) -> None:
import importlib.metadata

import pytest

dummy = "dummy"
version_old = "1.0.0"
version_new = "2.0.0"

mock_fetch.add_pkg_version(dummy, version_old)
mock_fetch.add_pkg_version(dummy, version_new)

await micropip.install(f"{dummy}=={version_new}")
assert micropip.list()[dummy].version == version_new
assert importlib.metadata.version(dummy) == version_new

with pytest.raises(ValueError, match="already installed"):
await micropip.install(f"{dummy}=={version_old}", reinstall=False)

await micropip.install(f"{dummy}=={version_old}", reinstall=True)
assert micropip.list()[dummy].version == version_old
assert importlib.metadata.version(dummy) == version_old


def test_logging(selenium_standalone_micropip):
# TODO: make a fixture for this, it's used in a few places
with spawn_web_server(Path(__file__).parent / "dist") as server:
Expand Down
1 change: 1 addition & 0 deletions tests/test_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def create_transaction(Transaction):
ctx={},
ctx_extras=[],
fetch_kwargs={},
reinstall=False,
)


Expand Down