Skip to content
Closed
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