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
20 changes: 20 additions & 0 deletions micropip/_commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from packaging.markers import default_environment

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

Expand All @@ -15,6 +16,7 @@ async def install(
deps: bool = True,
credentials: str | None = None,
pre: bool = False,
force_reinstall: bool = False,
) -> None:
"""Install the given package and all of its dependencies.

Expand Down Expand Up @@ -83,6 +85,9 @@ async def install(
If ``True``, include pre-release and development versions. By default,
micropip only finds stable versions.

force_reinstall :

If ``True``, reinstall all packages even if they are already up-to-date.
"""
ctx = default_environment()
if isinstance(requirements, str):
Expand All @@ -106,6 +111,7 @@ async def install(
keep_going=keep_going,
deps=deps,
pre=pre,
force_reinstall=force_reinstall,
fetch_kwargs=fetch_kwargs,
)
await transaction.gather_requirements(requirements)
Expand All @@ -117,6 +123,20 @@ async def install(
f"See: {FAQ_URLS['cant_find_wheel']}\n"
)

# 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

uninstall_distributions(distributions)

wheel_promises = []
# Install built-in packages
pyodide_packages = transaction.pyodide_packages
Expand Down
60 changes: 6 additions & 54 deletions micropip/_commands/uninstall.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import importlib
import importlib.metadata
import warnings
from collections.abc import Iterable
from importlib.metadata import Distribution

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


def uninstall(packages: str | list[str]) -> None:
def uninstall(packages: str | Iterable[str]) -> None:
"""Uninstall the given packages.

This function only supports uninstalling packages that are installed
Expand All @@ -34,58 +34,10 @@ def uninstall(packages: str | list[str]) -> None:
distributions.append(dist)
except importlib.metadata.PackageNotFoundError:
warnings.warn(
f"WARNING: Skipping '{package}' as it is not installed.", stacklevel=1
)

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

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

warnings.warn(
f"WARNING: A file '{file}' listed in the metadata of '{dist.name}' does not exist.",
stacklevel=1,
)

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:
warnings.warn(
f"WARNING: A directory '{directory}' is not empty after uninstallation of '{name}'. "
"This might cause problems when installing a new version of the package. ",
stacklevel=1,
)

if hasattr(loadedPackages, name):
delattr(loadedPackages, name)
else:
# This should not happen, but just in case
warnings.warn(
f"WARNING: a package '{name}' was not found in loadedPackages.",
f"WARNING: Skipping '{package}' as it is not installed.",
stacklevel=1,
)

uninstall_distributions(distributions)

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

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


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

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

warnings.warn(
f"WARNING: A file '{file}' listed in the metadata of '{dist.name}' does not exist.",
stacklevel=1,
)

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:
warnings.warn(
f"WARNING: A directory '{directory}' is not empty after uninstallation of '{name}'. "
"This might cause problems when installing a new version of the package. ",
stacklevel=1,
)

if hasattr(loadedPackages, name):
delattr(loadedPackages, name)
else:
# This should not happen, but just in case
warnings.warn(
f"WARNING: a package '{name}' was not found in loadedPackages.",
stacklevel=1,
)
13 changes: 8 additions & 5 deletions micropip/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ class Transaction:
keep_going: bool
deps: bool
pre: bool
force_reinstall: bool
fetch_kwargs: dict[str, str]

locked: dict[str, PackageMetadata] = field(default_factory=dict)
Expand Down Expand Up @@ -261,6 +262,10 @@ async def add_requirement(self, req: str | Requirement) -> None:
await self.add_wheel(wheel, extras=set())

def check_version_satisfied(self, req: Requirement) -> bool:
"""
Check if the installed version of a package satisfies the requirement.
Returns True if the requirement is satisfied, False otherwise.
"""
ver = None
try:
ver = importlib.metadata.version(req.name)
Expand All @@ -276,9 +281,7 @@ def check_version_satisfied(self, req: Requirement) -> bool:
# installed version matches, nothing to do
return True

raise ValueError(
f"Requested '{req}', " f"but {req.name}=={ver} is already installed"
)
return False

async def add_requirement_inner(
self,
Expand Down Expand Up @@ -330,7 +333,7 @@ def eval_marker(e: dict[str, str]) -> bool:
return
# Is some version of this package is already installed?
req.name = canonicalize_name(req.name)
if self.check_version_satisfied(req):
if not self.force_reinstall and self.check_version_satisfied(req):
return

# If there's a Pyodide package that matches the version constraint, use
Expand All @@ -355,7 +358,7 @@ def eval_marker(e: dict[str, str]) -> bool:
else:
return

if self.check_version_satisfied(req):
if not self.force_reinstall and self.check_version_satisfied(req):
# Maybe while we were downloading pypi_json some other branch
# installed the wheel?
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
59 changes: 59 additions & 0 deletions tests/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,3 +360,62 @@ 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

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_old}")
assert micropip.list()[dummy].version == version_old
assert importlib.metadata.version(dummy) == version_old

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

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


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

dummy = "dummy"
version_old = "1.0.0"

mock_fetch.add_pkg_version(dummy, version_old)

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

dist_path = importlib.metadata.distribution(dummy)._path # type: ignore[attr-defined]
assert dist_path.exists()

# create a dummy file in the dist_info directory, then force reinstall
# the package. The dummy file should be removed.
dummy_file = dist_path / "dummy"
dummy_file.touch()
assert dummy_file.exists()

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

assert not dummy_file.exists()
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={},
force_reinstall=False,
)


Expand Down