mirror of
				https://github.com/python/cpython.git
				synced 2025-10-31 13:41:24 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			529 lines
		
	
	
	
		
			17 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			529 lines
		
	
	
	
		
			17 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| """Building blocks for installers.
 | |
| 
 | |
| When used as a script, this module installs a release thanks to info
 | |
| obtained from an index (e.g. PyPI), with dependencies.
 | |
| 
 | |
| This is a higher-level module built on packaging.database and
 | |
| packaging.pypi.
 | |
| """
 | |
| import os
 | |
| import sys
 | |
| import stat
 | |
| import errno
 | |
| import shutil
 | |
| import logging
 | |
| import tempfile
 | |
| from sysconfig import get_config_var, get_path, is_python_build
 | |
| 
 | |
| from packaging import logger
 | |
| from packaging.dist import Distribution
 | |
| from packaging.util import (_is_archive_file, ask, get_install_method,
 | |
|                             egginfo_to_distinfo)
 | |
| from packaging.pypi import wrapper
 | |
| from packaging.version import get_version_predicate
 | |
| from packaging.database import get_distributions, get_distribution
 | |
| from packaging.depgraph import generate_graph
 | |
| 
 | |
| from packaging.errors import (PackagingError, InstallationException,
 | |
|                               InstallationConflict, CCompilerError)
 | |
| from packaging.pypi.errors import ProjectNotFound, ReleaseNotFound
 | |
| from packaging import database
 | |
| 
 | |
| 
 | |
| __all__ = ['install_dists', 'install_from_infos', 'get_infos', 'remove',
 | |
|            'install', 'install_local_project']
 | |
| 
 | |
| 
 | |
| def _move_files(files, destination):
 | |
|     """Move the list of files in the destination folder, keeping the same
 | |
|     structure.
 | |
| 
 | |
|     Return a list of tuple (old, new) emplacement of files
 | |
| 
 | |
|     :param files: a list of files to move.
 | |
|     :param destination: the destination directory to put on the files.
 | |
|     """
 | |
| 
 | |
|     for old in files:
 | |
|         filename = os.path.split(old)[-1]
 | |
|         new = os.path.join(destination, filename)
 | |
|         # try to make the paths.
 | |
|         try:
 | |
|             os.makedirs(os.path.dirname(new))
 | |
|         except OSError as e:
 | |
|             if e.errno != errno.EEXIST:
 | |
|                 raise
 | |
|         os.rename(old, new)
 | |
|         yield old, new
 | |
| 
 | |
| 
 | |
| def _run_distutils_install(path):
 | |
|     # backward compat: using setuptools or plain-distutils
 | |
|     cmd = '%s setup.py install --record=%s'
 | |
|     record_file = os.path.join(path, 'RECORD')
 | |
|     os.system(cmd % (sys.executable, record_file))
 | |
|     if not os.path.exists(record_file):
 | |
|         raise ValueError('failed to install')
 | |
|     else:
 | |
|         egginfo_to_distinfo(record_file, remove_egginfo=True)
 | |
| 
 | |
| 
 | |
| def _run_setuptools_install(path):
 | |
|     cmd = '%s setup.py install --record=%s --single-version-externally-managed'
 | |
|     record_file = os.path.join(path, 'RECORD')
 | |
| 
 | |
|     os.system(cmd % (sys.executable, record_file))
 | |
|     if not os.path.exists(record_file):
 | |
|         raise ValueError('failed to install')
 | |
|     else:
 | |
|         egginfo_to_distinfo(record_file, remove_egginfo=True)
 | |
| 
 | |
| 
 | |
| def _run_packaging_install(path):
 | |
|     # XXX check for a valid setup.cfg?
 | |
|     dist = Distribution()
 | |
|     dist.parse_config_files()
 | |
|     try:
 | |
|         dist.run_command('install_dist')
 | |
|         name = dist.metadata['Name']
 | |
|         return database.get_distribution(name) is not None
 | |
|     except (IOError, os.error, PackagingError, CCompilerError) as msg:
 | |
|         raise ValueError("Failed to install, " + str(msg))
 | |
| 
 | |
| 
 | |
| def _install_dist(dist, path):
 | |
|     """Install a distribution into a path.
 | |
| 
 | |
|     This:
 | |
| 
 | |
|     * unpack the distribution
 | |
|     * copy the files in "path"
 | |
|     * determine if the distribution is packaging or distutils1.
 | |
|     """
 | |
|     where = dist.unpack()
 | |
| 
 | |
|     if where is None:
 | |
|         raise ValueError('Cannot locate the unpacked archive')
 | |
| 
 | |
|     return _run_install_from_archive(where)
 | |
| 
 | |
| 
 | |
| def install_local_project(path):
 | |
|     """Install a distribution from a source directory.
 | |
| 
 | |
|     If the source directory contains a setup.py install using distutils1.
 | |
|     If a setup.cfg is found, install using the install_dist command.
 | |
| 
 | |
|     Returns True on success, False on Failure.
 | |
|     """
 | |
|     path = os.path.abspath(path)
 | |
|     if os.path.isdir(path):
 | |
|         logger.info('Installing from source directory: %r', path)
 | |
|         return _run_install_from_dir(path)
 | |
|     elif _is_archive_file(path):
 | |
|         logger.info('Installing from archive: %r', path)
 | |
|         _unpacked_dir = tempfile.mkdtemp()
 | |
|         try:
 | |
|             shutil.unpack_archive(path, _unpacked_dir)
 | |
|             return _run_install_from_archive(_unpacked_dir)
 | |
|         finally:
 | |
|             shutil.rmtree(_unpacked_dir)
 | |
|     else:
 | |
|         logger.warning('No project to install.')
 | |
|         return False
 | |
| 
 | |
| 
 | |
| def _run_install_from_archive(source_dir):
 | |
|     # XXX need a better way
 | |
|     for item in os.listdir(source_dir):
 | |
|         fullpath = os.path.join(source_dir, item)
 | |
|         if os.path.isdir(fullpath):
 | |
|             source_dir = fullpath
 | |
|             break
 | |
|     return _run_install_from_dir(source_dir)
 | |
| 
 | |
| 
 | |
| install_methods = {
 | |
|     'packaging': _run_packaging_install,
 | |
|     'setuptools': _run_setuptools_install,
 | |
|     'distutils': _run_distutils_install}
 | |
| 
 | |
| 
 | |
| def _run_install_from_dir(source_dir):
 | |
|     old_dir = os.getcwd()
 | |
|     os.chdir(source_dir)
 | |
|     install_method = get_install_method(source_dir)
 | |
|     func = install_methods[install_method]
 | |
|     try:
 | |
|         func = install_methods[install_method]
 | |
|         try:
 | |
|             func(source_dir)
 | |
|             return True
 | |
|         except ValueError as err:
 | |
|             # failed to install
 | |
|             logger.info(str(err))
 | |
|             return False
 | |
|     finally:
 | |
|         os.chdir(old_dir)
 | |
| 
 | |
| 
 | |
| def install_dists(dists, path, paths=None):
 | |
|     """Install all distributions provided in dists, with the given prefix.
 | |
| 
 | |
|     If an error occurs while installing one of the distributions, uninstall all
 | |
|     the installed distribution (in the context if this function).
 | |
| 
 | |
|     Return a list of installed dists.
 | |
| 
 | |
|     :param dists: distributions to install
 | |
|     :param path: base path to install distribution in
 | |
|     :param paths: list of paths (defaults to sys.path) to look for info
 | |
|     """
 | |
| 
 | |
|     installed_dists = []
 | |
|     for dist in dists:
 | |
|         logger.info('Installing %r %s...', dist.name, dist.version)
 | |
|         try:
 | |
|             _install_dist(dist, path)
 | |
|             installed_dists.append(dist)
 | |
|         except Exception as e:
 | |
|             logger.info('Failed: %s', e)
 | |
| 
 | |
|             # reverting
 | |
|             for installed_dist in installed_dists:
 | |
|                 logger.info('Reverting %r', installed_dist)
 | |
|                 remove(installed_dist.name, paths)
 | |
|             raise e
 | |
|     return installed_dists
 | |
| 
 | |
| 
 | |
| def install_from_infos(install_path=None, install=[], remove=[], conflicts=[],
 | |
|                        paths=None):
 | |
|     """Install and remove the given distributions.
 | |
| 
 | |
|     The function signature is made to be compatible with the one of get_infos.
 | |
|     The aim of this script is to povide a way to install/remove what's asked,
 | |
|     and to rollback if needed.
 | |
| 
 | |
|     So, it's not possible to be in an inconsistant state, it could be either
 | |
|     installed, either uninstalled, not half-installed.
 | |
| 
 | |
|     The process follow those steps:
 | |
| 
 | |
|         1. Move all distributions that will be removed in a temporary location
 | |
|         2. Install all the distributions that will be installed in a temp. loc.
 | |
|         3. If the installation fails, rollback (eg. move back) those
 | |
|            distributions, or remove what have been installed.
 | |
|         4. Else, move the distributions to the right locations, and remove for
 | |
|            real the distributions thats need to be removed.
 | |
| 
 | |
|     :param install_path: the installation path where we want to install the
 | |
|                          distributions.
 | |
|     :param install: list of distributions that will be installed; install_path
 | |
|                     must be provided if this list is not empty.
 | |
|     :param remove: list of distributions that will be removed.
 | |
|     :param conflicts: list of conflicting distributions, eg. that will be in
 | |
|                       conflict once the install and remove distribution will be
 | |
|                       processed.
 | |
|     :param paths: list of paths (defaults to sys.path) to look for info
 | |
|     """
 | |
|     # first of all, if we have conflicts, stop here.
 | |
|     if conflicts:
 | |
|         raise InstallationConflict(conflicts)
 | |
| 
 | |
|     if install and not install_path:
 | |
|         raise ValueError("Distributions are to be installed but `install_path`"
 | |
|                          " is not provided.")
 | |
| 
 | |
|     # before removing the files, we will start by moving them away
 | |
|     # then, if any error occurs, we could replace them in the good place.
 | |
|     temp_files = {}  # contains lists of {dist: (old, new)} paths
 | |
|     temp_dir = None
 | |
|     if remove:
 | |
|         temp_dir = tempfile.mkdtemp()
 | |
|         for dist in remove:
 | |
|             files = dist.list_installed_files()
 | |
|             temp_files[dist] = _move_files(files, temp_dir)
 | |
|     try:
 | |
|         if install:
 | |
|             install_dists(install, install_path, paths)
 | |
|     except:
 | |
|         # if an error occurs, put back the files in the right place.
 | |
|         for files in temp_files.values():
 | |
|             for old, new in files:
 | |
|                 shutil.move(new, old)
 | |
|         if temp_dir:
 | |
|             shutil.rmtree(temp_dir)
 | |
|         # now re-raising
 | |
|         raise
 | |
| 
 | |
|     # we can remove them for good
 | |
|     for files in temp_files.values():
 | |
|         for old, new in files:
 | |
|             os.remove(new)
 | |
|     if temp_dir:
 | |
|         shutil.rmtree(temp_dir)
 | |
| 
 | |
| 
 | |
| def _get_setuptools_deps(release):
 | |
|     # NotImplementedError
 | |
|     pass
 | |
| 
 | |
| 
 | |
| def get_infos(requirements, index=None, installed=None, prefer_final=True):
 | |
|     """Return the informations on what's going to be installed and upgraded.
 | |
| 
 | |
|     :param requirements: is a *string* containing the requirements for this
 | |
|                          project (for instance "FooBar 1.1" or "BarBaz (<1.2)")
 | |
|     :param index: If an index is specified, use this one, otherwise, use
 | |
|                   :class index.ClientWrapper: to get project metadatas.
 | |
|     :param installed: a list of already installed distributions.
 | |
|     :param prefer_final: when picking up the releases, prefer a "final" one
 | |
|                          over a beta/alpha/etc one.
 | |
| 
 | |
|     The results are returned in a dict, containing all the operations
 | |
|     needed to install the given requirements::
 | |
| 
 | |
|         >>> get_install_info("FooBar (<=1.2)")
 | |
|         {'install': [<FooBar 1.1>], 'remove': [], 'conflict': []}
 | |
| 
 | |
|     Conflict contains all the conflicting distributions, if there is a
 | |
|     conflict.
 | |
|     """
 | |
|     # this function does several things:
 | |
|     # 1. get a release specified by the requirements
 | |
|     # 2. gather its metadata, using setuptools compatibility if needed
 | |
|     # 3. compare this tree with what is currently installed on the system,
 | |
|     #    return the requirements of what is missing
 | |
|     # 4. do that recursively and merge back the results
 | |
|     # 5. return a dict containing information about what is needed to install
 | |
|     #    or remove
 | |
| 
 | |
|     if not installed:
 | |
|         logger.debug('Reading installed distributions')
 | |
|         installed = list(get_distributions(use_egg_info=True))
 | |
| 
 | |
|     infos = {'install': [], 'remove': [], 'conflict': []}
 | |
|     # Is a compatible version of the project already installed ?
 | |
|     predicate = get_version_predicate(requirements)
 | |
|     found = False
 | |
| 
 | |
|     # check that the project isn't already installed
 | |
|     for installed_project in installed:
 | |
|         # is it a compatible project ?
 | |
|         if predicate.name.lower() != installed_project.name.lower():
 | |
|             continue
 | |
|         found = True
 | |
|         logger.info('Found %r %s', installed_project.name,
 | |
|                     installed_project.version)
 | |
| 
 | |
|         # if we already have something installed, check it matches the
 | |
|         # requirements
 | |
|         if predicate.match(installed_project.version):
 | |
|             return infos
 | |
|         break
 | |
| 
 | |
|     if not found:
 | |
|         logger.debug('Project not installed')
 | |
| 
 | |
|     if not index:
 | |
|         index = wrapper.ClientWrapper()
 | |
| 
 | |
|     if not installed:
 | |
|         installed = get_distributions(use_egg_info=True)
 | |
| 
 | |
|     # Get all the releases that match the requirements
 | |
|     try:
 | |
|         release = index.get_release(requirements)
 | |
|     except (ReleaseNotFound, ProjectNotFound):
 | |
|         raise InstallationException('Release not found: %r' % requirements)
 | |
| 
 | |
|     if release is None:
 | |
|         logger.info('Could not find a matching project')
 | |
|         return infos
 | |
| 
 | |
|     metadata = release.fetch_metadata()
 | |
| 
 | |
|     # we need to build setuptools deps if any
 | |
|     if 'requires_dist' not in metadata:
 | |
|         metadata['requires_dist'] = _get_setuptools_deps(release)
 | |
| 
 | |
|     # build the dependency graph with local and required dependencies
 | |
|     dists = list(installed)
 | |
|     dists.append(release)
 | |
|     depgraph = generate_graph(dists)
 | |
| 
 | |
|     # Get what the missing deps are
 | |
|     dists = depgraph.missing[release]
 | |
|     if dists:
 | |
|         logger.info("Missing dependencies found, retrieving metadata")
 | |
|         # we have missing deps
 | |
|         for dist in dists:
 | |
|             _update_infos(infos, get_infos(dist, index, installed))
 | |
| 
 | |
|     # Fill in the infos
 | |
|     existing = [d for d in installed if d.name == release.name]
 | |
|     if existing:
 | |
|         infos['remove'].append(existing[0])
 | |
|         infos['conflict'].extend(depgraph.reverse_list[existing[0]])
 | |
|     infos['install'].append(release)
 | |
|     return infos
 | |
| 
 | |
| 
 | |
| def _update_infos(infos, new_infos):
 | |
|     """extends the lists contained in the `info` dict with those contained
 | |
|     in the `new_info` one
 | |
|     """
 | |
|     for key, value in infos.items():
 | |
|         if key in new_infos:
 | |
|             infos[key].extend(new_infos[key])
 | |
| 
 | |
| 
 | |
| def remove(project_name, paths=None, auto_confirm=True):
 | |
|     """Removes a single project from the installation.
 | |
| 
 | |
|     Returns True on success
 | |
|     """
 | |
|     dist = get_distribution(project_name, use_egg_info=True, paths=paths)
 | |
|     if dist is None:
 | |
|         raise PackagingError('Distribution %r not found' % project_name)
 | |
|     files = dist.list_installed_files(local=True)
 | |
|     rmdirs = []
 | |
|     rmfiles = []
 | |
|     tmp = tempfile.mkdtemp(prefix=project_name + '-uninstall')
 | |
| 
 | |
|     def _move_file(source, target):
 | |
|         try:
 | |
|             os.rename(source, target)
 | |
|         except OSError as err:
 | |
|             return err
 | |
|         return None
 | |
| 
 | |
|     success = True
 | |
|     error = None
 | |
|     try:
 | |
|         for file_, md5, size in files:
 | |
|             if os.path.isfile(file_):
 | |
|                 dirname, filename = os.path.split(file_)
 | |
|                 tmpfile = os.path.join(tmp, filename)
 | |
|                 try:
 | |
|                     error = _move_file(file_, tmpfile)
 | |
|                     if error is not None:
 | |
|                         success = False
 | |
|                         break
 | |
|                 finally:
 | |
|                     if not os.path.isfile(file_):
 | |
|                         os.rename(tmpfile, file_)
 | |
|                 if file_ not in rmfiles:
 | |
|                     rmfiles.append(file_)
 | |
|                 if dirname not in rmdirs:
 | |
|                     rmdirs.append(dirname)
 | |
|     finally:
 | |
|         shutil.rmtree(tmp)
 | |
| 
 | |
|     if not success:
 | |
|         logger.info('%r cannot be removed.', project_name)
 | |
|         logger.info('Error: %s', error)
 | |
|         return False
 | |
| 
 | |
|     logger.info('Removing %r: ', project_name)
 | |
| 
 | |
|     for file_ in rmfiles:
 | |
|         logger.info('  %s', file_)
 | |
| 
 | |
|     # Taken from the pip project
 | |
|     if auto_confirm:
 | |
|         response = 'y'
 | |
|     else:
 | |
|         response = ask('Proceed (y/n)? ', ('y', 'n'))
 | |
| 
 | |
|     if response == 'y':
 | |
|         file_count = 0
 | |
|         for file_ in rmfiles:
 | |
|             os.remove(file_)
 | |
|             file_count += 1
 | |
| 
 | |
|         dir_count = 0
 | |
|         for dirname in rmdirs:
 | |
|             if not os.path.exists(dirname):
 | |
|                 # could
 | |
|                 continue
 | |
| 
 | |
|             files_count = 0
 | |
|             for root, dir, files in os.walk(dirname):
 | |
|                 files_count += len(files)
 | |
| 
 | |
|             if files_count > 0:
 | |
|                 # XXX Warning
 | |
|                 continue
 | |
| 
 | |
|             # empty dirs with only empty dirs
 | |
|             if os.stat(dirname).st_mode & stat.S_IWUSR:
 | |
|                 # XXX Add a callable in shutil.rmtree to count
 | |
|                 # the number of deleted elements
 | |
|                 shutil.rmtree(dirname)
 | |
|                 dir_count += 1
 | |
| 
 | |
|         # removing the top path
 | |
|         # XXX count it ?
 | |
|         if os.path.exists(dist.path):
 | |
|             shutil.rmtree(dist.path)
 | |
| 
 | |
|         logger.info('Success: removed %d files and %d dirs',
 | |
|                     file_count, dir_count)
 | |
| 
 | |
|     return True
 | |
| 
 | |
| 
 | |
| def install(project):
 | |
|     """Installs a project.
 | |
| 
 | |
|     Returns True on success, False on failure
 | |
|     """
 | |
|     if is_python_build():
 | |
|         # Python would try to install into the site-packages directory under
 | |
|         # $PREFIX, but when running from an uninstalled code checkout we don't
 | |
|         # want to create directories under the installation root
 | |
|         message = ('installing third-party projects from an uninstalled '
 | |
|                    'Python is not supported')
 | |
|         logger.error(message)
 | |
|         return False
 | |
| 
 | |
|     logger.info('Checking the installation location...')
 | |
|     purelib_path = get_path('purelib')
 | |
| 
 | |
|     # trying to write a file there
 | |
|     try:
 | |
|         with tempfile.NamedTemporaryFile(suffix=project,
 | |
|                                          dir=purelib_path) as testfile:
 | |
|             testfile.write(b'test')
 | |
|     except OSError:
 | |
|         # FIXME this should check the errno, or be removed altogether (race
 | |
|         # condition: the directory permissions could be changed between here
 | |
|         # and the actual install)
 | |
|         logger.info('Unable to write in "%s". Do you have the permissions ?'
 | |
|                     % purelib_path)
 | |
|         return False
 | |
| 
 | |
|     logger.info('Getting information about %r...', project)
 | |
|     try:
 | |
|         info = get_infos(project)
 | |
|     except InstallationException:
 | |
|         logger.info('Cound not find %r', project)
 | |
|         return False
 | |
| 
 | |
|     if info['install'] == []:
 | |
|         logger.info('Nothing to install')
 | |
|         return False
 | |
| 
 | |
|     install_path = get_config_var('base')
 | |
|     try:
 | |
|         install_from_infos(install_path,
 | |
|                            info['install'], info['remove'], info['conflict'])
 | |
| 
 | |
|     except InstallationConflict as e:
 | |
|         if logger.isEnabledFor(logging.INFO):
 | |
|             projects = ('%r %s' % (p.name, p.version) for p in e.args[0])
 | |
|             logger.info('%r conflicts with %s', project, ','.join(projects))
 | |
| 
 | |
|     return True
 | 
