Skip to content

Commit cca0157

Browse files
ENH Add support for reinstalling packages (take 2) (#206)
* Implement reinstall * Use different wheel * Fix param * Use integration test [integration] * Fix test [integration] * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add reinstall check * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * typo * Fix bad merge * [integration] * fix test [integration] * Fix test [integration] --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 76586d4 commit cca0157

File tree

5 files changed

+239
-75
lines changed

5 files changed

+239
-75
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88

99
## [0.10.0] - 2024/07/02
1010

11+
### Added
12+
13+
- Added `reinstall` parameter to micropip.install to allow reinstalling
14+
a package that is already installed
15+
[#64](https://github.com/pyodide/micropip/pull/64)
16+
1117
### Fixed
1218

1319
- micropip now respects the `yanked` flag in the PyPI Simple API.

micropip/package_manager.py

Lines changed: 133 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import builtins
33
import importlib
44
import importlib.metadata
5+
import logging
6+
from collections.abc import Iterable
57
from importlib.metadata import Distribution
68
from pathlib import Path
79
from typing import ( # noqa: UP035 List import is necessary due to the `list` method
@@ -14,7 +16,7 @@
1416
from ._vendored.packaging.src.packaging.markers import default_environment
1517
from .constants import FAQ_URLS
1618
from .freeze import freeze_lockfile
17-
from .logging import setup_logging
19+
from .logging import indent_log, setup_logging
1820
from .package import PackageDict, PackageMetadata
1921
from .transaction import Transaction
2022

@@ -48,6 +50,7 @@ async def install(
4850
index_urls: list[str] | str | None = None,
4951
*,
5052
constraints: list[str] | None = None,
53+
reinstall: bool = False,
5154
verbose: bool | int | None = None,
5255
) -> None:
5356
"""Install the given package and all of its dependencies.
@@ -140,6 +143,16 @@ async def install(
140143
Unlike ``requirements``, the package name _must_ be provided in the
141144
PEP-508 format e.g. ``pkgname@https://...``.
142145
146+
reinstall:
147+
148+
If ``False`` (default), micropip will show an error if the requested package
149+
is already installed, but with a incompatible version. If ``True``,
150+
micropip will uninstall the existing packages that are not compatible with
151+
the requested version and install the packages again.
152+
153+
Note that packages that are already imported will not be reloaded, so make
154+
sure to reload the module after reinstalling by e.g. running importlib.reload(module).
155+
143156
verbose:
144157
Print more information about the process. By default, micropip does not
145158
change logger level. Setting ``verbose=True`` will print similar
@@ -180,6 +193,7 @@ async def install(
180193
verbose=verbose,
181194
index_urls=index_urls,
182195
constraints=constraints,
196+
reinstall=reinstall,
183197
)
184198
await transaction.gather_requirements(requirements)
185199

@@ -194,17 +208,25 @@ async def install(
194208

195209
pyodide_packages, wheels = transaction.pyodide_packages, transaction.wheels
196210

197-
package_names = [pkg.name for pkg in wheels + pyodide_packages]
211+
packages_all = [pkg.name for pkg in wheels + pyodide_packages]
212+
213+
distributions = search_installed_packages(packages_all)
214+
# This check is redundant because the distributions will always be an empty list when reinstall==False
215+
# (no installed packages will be returned from transaction)
216+
# But just in case.
217+
if reinstall:
218+
with indent_log():
219+
self._uninstall_distributions(distributions, logger)
198220

199221
logger.debug(
200222
"Installing packages %r and wheels %r ",
201223
transaction.pyodide_packages,
202224
[w.filename for w in transaction.wheels],
203225
)
204226

205-
if package_names:
227+
if packages_all:
206228
logger.info(
207-
"Installing collected packages: %s", ", ".join(package_names)
229+
"Installing collected packages: %s", ", ".join(packages_all)
208230
)
209231

210232
# Install PyPI packages
@@ -423,68 +445,7 @@ def uninstall(
423445
except importlib.metadata.PackageNotFoundError:
424446
logger.warning("Skipping '%s' as it is not installed.", package)
425447

426-
for dist in distributions:
427-
# Note: this value needs to be retrieved before removing files, as
428-
# dist.name uses metadata file to get the name
429-
name = dist.name
430-
version = dist.version
431-
432-
logger.info("Found existing installation: %s %s", name, version)
433-
434-
root = get_root(dist)
435-
files = get_files_in_distribution(dist)
436-
directories = set()
437-
438-
for file in files:
439-
if not file.is_file():
440-
if not file.is_relative_to(root):
441-
# This file is not in the site-packages directory. Probably one of:
442-
# - data_files
443-
# - scripts
444-
# - entry_points
445-
# Since we don't support these, we can ignore them (except for data_files (TODO))
446-
logger.warning(
447-
"skipping file '%s' that is relative to root",
448-
)
449-
continue
450-
# see PR 130, it is likely that this is never triggered since Python 3.12
451-
# as non existing files are not listed by get_files_in_distribution anymore.
452-
logger.warning(
453-
"A file '%s' listed in the metadata of '%s' does not exist.",
454-
file,
455-
name,
456-
)
457-
458-
continue
459-
460-
file.unlink()
461-
462-
if file.parent != root:
463-
directories.add(file.parent)
464-
465-
# Remove directories in reverse hierarchical order
466-
for directory in sorted(
467-
directories, key=lambda x: len(x.parts), reverse=True
468-
):
469-
try:
470-
directory.rmdir()
471-
except OSError:
472-
logger.warning(
473-
"A directory '%s' is not empty after uninstallation of '%s'. "
474-
"This might cause problems when installing a new version of the package. ",
475-
directory,
476-
name,
477-
)
478-
479-
if hasattr(self.compat_layer.loadedPackages, name):
480-
delattr(self.compat_layer.loadedPackages, name)
481-
else:
482-
# This should not happen, but just in case
483-
logger.warning(
484-
"a package '%s' was not found in loadedPackages.", name
485-
)
486-
487-
logger.info("Successfully uninstalled %s-%s", name, version)
448+
self._uninstall_distributions(distributions, logger)
488449

489450
importlib.invalidate_caches()
490451

@@ -525,3 +486,109 @@ def set_constraints(self, constraints: List[str]): # noqa: UP006
525486
"""
526487

527488
self.constraints = constraints[:]
489+
490+
def _uninstall_distributions(
491+
self,
492+
distributions: Iterable[Distribution],
493+
logger: logging.Logger, # TODO: move this to an attribute of the PackageManager
494+
) -> None:
495+
"""
496+
Uninstall the given package distributions.
497+
498+
This function does not do any checks, so make sure that the distributions
499+
are installed and that they are installed using a wheel file, i.e. packages
500+
that have distribution metadata.
501+
502+
This function also does not invalidate the import cache, so make sure to
503+
call `importlib.invalidate_caches()` after calling this function.
504+
505+
Parameters
506+
----------
507+
distributions
508+
Package distributions to uninstall.
509+
510+
"""
511+
for dist in distributions:
512+
# Note: this value needs to be retrieved before removing files, as
513+
# dist.name uses metadata file to get the name
514+
name = dist.name
515+
version = dist.version
516+
517+
logger.info("Found existing installation: %s %s", name, version)
518+
519+
root = get_root(dist)
520+
files = get_files_in_distribution(dist)
521+
directories = set()
522+
523+
for file in files:
524+
if not file.is_file():
525+
if not file.is_relative_to(root):
526+
# This file is not in the site-packages directory. Probably one of:
527+
# - data_files
528+
# - scripts
529+
# - entry_points
530+
# Since we don't support these, we can ignore them (except for data_files (TODO))
531+
logger.warning(
532+
"skipping file '%s' that is relative to root",
533+
)
534+
continue
535+
# see PR 130, it is likely that this is never triggered since Python 3.12
536+
# as non existing files are not listed by get_files_in_distribution anymore.
537+
logger.warning(
538+
"A file '%s' listed in the metadata of '%s' does not exist.",
539+
file,
540+
name,
541+
)
542+
543+
continue
544+
545+
file.unlink()
546+
547+
if file.parent != root:
548+
directories.add(file.parent)
549+
550+
# Remove directories in reverse hierarchical order
551+
for directory in sorted(
552+
directories, key=lambda x: len(x.parts), reverse=True
553+
):
554+
try:
555+
directory.rmdir()
556+
except OSError:
557+
logger.warning(
558+
"A directory '%s' is not empty after uninstallation of '%s'. "
559+
"This might cause problems when installing a new version of the package. ",
560+
directory,
561+
name,
562+
)
563+
564+
if hasattr(self.compat_layer.loadedPackages, name):
565+
delattr(self.compat_layer.loadedPackages, name)
566+
else:
567+
# This should not happen, but just in case
568+
logger.warning("a package '%s' was not found in loadedPackages.", name)
569+
570+
logger.info("Successfully uninstalled %s-%s", name, version)
571+
572+
573+
def search_installed_packages(
574+
names: list[str],
575+
) -> list[importlib.metadata.Distribution]:
576+
"""
577+
Get installed packages by name.
578+
Parameters
579+
----------
580+
names
581+
List of distribution names to search for.
582+
Returns
583+
-------
584+
List of distributions that were found.
585+
If a distribution is not found, it is not included in the list.
586+
"""
587+
distributions = []
588+
for name in names:
589+
try:
590+
distributions.append(importlib.metadata.distribution(name))
591+
except importlib.metadata.PackageNotFoundError:
592+
pass
593+
594+
return distributions

micropip/transaction.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ class Transaction:
4747

4848
verbose: bool | int | None = None
4949
constraints: list[str] | None = None
50+
reinstall: bool = False
5051

5152
def __post_init__(self) -> None:
5253
# If index_urls is None, pyodide-lock.json have to be searched first.
@@ -96,7 +97,22 @@ async def add_requirement(self, req: str | Requirement) -> None:
9697

9798
return await self.add_requirement_inner(Requirement(req))
9899

99-
def check_version_satisfied(self, req: Requirement) -> tuple[bool, str]:
100+
def check_version_satisfied(
101+
self, req: Requirement, *, allow_reinstall: bool = False
102+
) -> tuple[bool, str]:
103+
"""
104+
Check if the installed version of a package satisfies the requirement.
105+
Returns True if the requirement is satisfied, False otherwise.
106+
107+
Parameters
108+
----------
109+
req
110+
The requirement to check.
111+
112+
allow_reinstall
113+
If False, this function will raise exception if the package is already installed
114+
and the installed version does not satisfy the requirement.
115+
"""
100116
ver = None
101117
try:
102118
ver = importlib.metadata.version(req.name)
@@ -112,9 +128,16 @@ def check_version_satisfied(self, req: Requirement) -> tuple[bool, str]:
112128
# installed version matches, nothing to do
113129
return True, ver
114130

115-
raise ValueError(
116-
f"Requested '{req}', " f"but {req.name}=={ver} is already installed"
117-
)
131+
if allow_reinstall:
132+
return False, ""
133+
else:
134+
raise ValueError(
135+
f"Requested '{req}', "
136+
f"but {req.name}=={ver} is already installed. "
137+
"If you want to reinstall the package with a different version, "
138+
"use micropip.install(..., reinstall=True) to force reinstall, "
139+
"or micropip.uninstall(...) to uninstall the package first."
140+
)
118141

119142
async def add_requirement_inner(
120143
self,
@@ -171,7 +194,10 @@ def eval_marker(e: dict[str, str]) -> bool:
171194
# Is some version of this package is already installed?
172195
req.name = canonicalize_name(req.name)
173196

174-
satisfied, ver = self.check_version_satisfied(req)
197+
satisfied, ver = self.check_version_satisfied(
198+
req, allow_reinstall=self.reinstall
199+
)
200+
175201
if satisfied:
176202
logger.info("Requirement already satisfied: %s (%s)", req, ver)
177203
return
@@ -254,7 +280,9 @@ async def _add_requirement_from_package_index(self, req: Requirement):
254280

255281
# Maybe while we were downloading pypi_json some other branch
256282
# installed the wheel?
257-
satisfied, ver = self.check_version_satisfied(req)
283+
satisfied, ver = self.check_version_satisfied(
284+
req, allow_reinstall=self.reinstall
285+
)
258286
if satisfied:
259287
logger.info("Requirement already satisfied: %s (%s)", req, ver)
260288

0 commit comments

Comments
 (0)