22import builtins
33import importlib
44import importlib .metadata
5+ import logging
6+ from collections .abc import Iterable
57from importlib .metadata import Distribution
68from pathlib import Path
79from typing import ( # noqa: UP035 List import is necessary due to the `list` method
1416from ._vendored .packaging .src .packaging .markers import default_environment
1517from .constants import FAQ_URLS
1618from .freeze import freeze_lockfile
17- from .logging import setup_logging
19+ from .logging import indent_log , setup_logging
1820from .package import PackageDict , PackageMetadata
1921from .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
0 commit comments