diff --git a/CHANGELOG.md b/CHANGELOG.md index efe35a4..465de4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/micropip/_commands/install.py b/micropip/_commands/install.py index a636491..3a8bf1b 100644 --- a/micropip/_commands/install.py +++ b/micropip/_commands/install.py @@ -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 @@ -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: @@ -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 @@ -115,6 +121,7 @@ async def install( keep_going=keep_going, deps=deps, pre=pre, + reinstall=reinstall, fetch_kwargs=fetch_kwargs, verbose=verbose, ) @@ -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 diff --git a/micropip/_commands/uninstall.py b/micropip/_commands/uninstall.py index a22f3e7..69a8f46 100644 --- a/micropip/_commands/uninstall.py +++ b/micropip/_commands/uninstall.py @@ -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 @@ -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() diff --git a/micropip/_uninstall.py b/micropip/_uninstall.py new file mode 100644 index 0000000..eb19852 --- /dev/null +++ b/micropip/_uninstall.py @@ -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}") diff --git a/micropip/transaction.py b/micropip/transaction.py index 2fba6e6..4cecaa1 100644 --- a/micropip/transaction.py +++ b/micropip/transaction.py @@ -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) @@ -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) @@ -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, @@ -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 @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 64e661c..f427067 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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: diff --git a/tests/test_install.py b/tests/test_install.py index b3b53a8..934faa1 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -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: diff --git a/tests/test_transaction.py b/tests/test_transaction.py index 6bed1cd..65d368c 100644 --- a/tests/test_transaction.py +++ b/tests/test_transaction.py @@ -63,6 +63,7 @@ def create_transaction(Transaction): ctx={}, ctx_extras=[], fetch_kwargs={}, + reinstall=False, )