blob: 776ba4014c3cc6355f856ac06e846b293c9bf3a9 [file] [log] [blame]
Tarek Ziade1231a4e2011-05-19 13:07:25 +02001"""Building blocks for installers.
2
3When used as a script, this module installs a release thanks to info
4obtained from an index (e.g. PyPI), with dependencies.
5
6This is a higher-level module built on packaging.database and
7packaging.pypi.
8"""
Tarek Ziade1231a4e2011-05-19 13:07:25 +02009import os
10import sys
11import stat
12import errno
13import shutil
14import logging
15import tempfile
Éric Araujofa6cfbc2011-06-10 18:31:40 +020016from sysconfig import get_config_var, get_path, is_python_build
Tarek Ziade1231a4e2011-05-19 13:07:25 +020017
18from packaging import logger
19from packaging.dist import Distribution
20from packaging.util import (_is_archive_file, ask, get_install_method,
21 egginfo_to_distinfo)
22from packaging.pypi import wrapper
23from packaging.version import get_version_predicate
24from packaging.database import get_distributions, get_distribution
25from packaging.depgraph import generate_graph
26
27from packaging.errors import (PackagingError, InstallationException,
28 InstallationConflict, CCompilerError)
29from packaging.pypi.errors import ProjectNotFound, ReleaseNotFound
Tarek Ziade5a5ce382011-05-31 12:09:34 +020030from packaging import database
31
Tarek Ziade1231a4e2011-05-19 13:07:25 +020032
33__all__ = ['install_dists', 'install_from_infos', 'get_infos', 'remove',
34 'install', 'install_local_project']
35
36
37def _move_files(files, destination):
38 """Move the list of files in the destination folder, keeping the same
39 structure.
40
41 Return a list of tuple (old, new) emplacement of files
42
43 :param files: a list of files to move.
44 :param destination: the destination directory to put on the files.
Tarek Ziade1231a4e2011-05-19 13:07:25 +020045 """
Tarek Ziade1231a4e2011-05-19 13:07:25 +020046
47 for old in files:
Tarek Ziade4bdd9f32011-05-21 15:12:10 +020048 filename = os.path.split(old)[-1]
49 new = os.path.join(destination, filename)
Tarek Ziade1231a4e2011-05-19 13:07:25 +020050 # try to make the paths.
51 try:
52 os.makedirs(os.path.dirname(new))
53 except OSError as e:
Éric Araujo6f677652011-06-16 23:43:15 +020054 if e.errno != errno.EEXIST:
55 raise
Tarek Ziade1231a4e2011-05-19 13:07:25 +020056 os.rename(old, new)
57 yield old, new
58
59
60def _run_distutils_install(path):
61 # backward compat: using setuptools or plain-distutils
62 cmd = '%s setup.py install --record=%s'
63 record_file = os.path.join(path, 'RECORD')
64 os.system(cmd % (sys.executable, record_file))
65 if not os.path.exists(record_file):
66 raise ValueError('failed to install')
67 else:
68 egginfo_to_distinfo(record_file, remove_egginfo=True)
69
70
71def _run_setuptools_install(path):
72 cmd = '%s setup.py install --record=%s --single-version-externally-managed'
73 record_file = os.path.join(path, 'RECORD')
Tarek Ziade5a5ce382011-05-31 12:09:34 +020074
Tarek Ziade1231a4e2011-05-19 13:07:25 +020075 os.system(cmd % (sys.executable, record_file))
76 if not os.path.exists(record_file):
77 raise ValueError('failed to install')
78 else:
79 egginfo_to_distinfo(record_file, remove_egginfo=True)
80
81
82def _run_packaging_install(path):
83 # XXX check for a valid setup.cfg?
84 dist = Distribution()
85 dist.parse_config_files()
86 try:
87 dist.run_command('install_dist')
Éric Araujobab50cb2011-07-29 02:37:21 +020088 name = dist.metadata['Name']
Tarek Ziade5a5ce382011-05-31 12:09:34 +020089 return database.get_distribution(name) is not None
Tarek Ziade1231a4e2011-05-19 13:07:25 +020090 except (IOError, os.error, PackagingError, CCompilerError) as msg:
Tarek Ziade5a5ce382011-05-31 12:09:34 +020091 raise ValueError("Failed to install, " + str(msg))
Tarek Ziade1231a4e2011-05-19 13:07:25 +020092
93
94def _install_dist(dist, path):
95 """Install a distribution into a path.
96
97 This:
98
99 * unpack the distribution
100 * copy the files in "path"
101 * determine if the distribution is packaging or distutils1.
102 """
103 where = dist.unpack()
104
105 if where is None:
106 raise ValueError('Cannot locate the unpacked archive')
107
108 return _run_install_from_archive(where)
109
110
111def install_local_project(path):
112 """Install a distribution from a source directory.
113
114 If the source directory contains a setup.py install using distutils1.
115 If a setup.cfg is found, install using the install_dist command.
116
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200117 Returns True on success, False on Failure.
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200118 """
119 path = os.path.abspath(path)
120 if os.path.isdir(path):
Éric Araujobab50cb2011-07-29 02:37:21 +0200121 logger.info('Installing from source directory: %r', path)
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200122 return _run_install_from_dir(path)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200123 elif _is_archive_file(path):
Éric Araujobab50cb2011-07-29 02:37:21 +0200124 logger.info('Installing from archive: %r', path)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200125 _unpacked_dir = tempfile.mkdtemp()
Vinay Sajip0bec35d2011-07-07 12:59:31 +0100126 try:
127 shutil.unpack_archive(path, _unpacked_dir)
128 return _run_install_from_archive(_unpacked_dir)
129 finally:
130 shutil.rmtree(_unpacked_dir)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200131 else:
Éric Araujobab50cb2011-07-29 02:37:21 +0200132 logger.warning('No project to install.')
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200133 return False
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200134
135
136def _run_install_from_archive(source_dir):
137 # XXX need a better way
138 for item in os.listdir(source_dir):
139 fullpath = os.path.join(source_dir, item)
140 if os.path.isdir(fullpath):
141 source_dir = fullpath
142 break
143 return _run_install_from_dir(source_dir)
144
145
146install_methods = {
147 'packaging': _run_packaging_install,
148 'setuptools': _run_setuptools_install,
149 'distutils': _run_distutils_install}
150
151
152def _run_install_from_dir(source_dir):
153 old_dir = os.getcwd()
154 os.chdir(source_dir)
155 install_method = get_install_method(source_dir)
156 func = install_methods[install_method]
157 try:
158 func = install_methods[install_method]
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200159 try:
160 func(source_dir)
161 return True
162 except ValueError as err:
163 # failed to install
164 logger.info(str(err))
165 return False
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200166 finally:
167 os.chdir(old_dir)
168
169
Éric Araujo6f677652011-06-16 23:43:15 +0200170def install_dists(dists, path, paths=None):
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200171 """Install all distributions provided in dists, with the given prefix.
172
173 If an error occurs while installing one of the distributions, uninstall all
174 the installed distribution (in the context if this function).
175
176 Return a list of installed dists.
177
178 :param dists: distributions to install
179 :param path: base path to install distribution in
180 :param paths: list of paths (defaults to sys.path) to look for info
181 """
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200182
183 installed_dists = []
184 for dist in dists:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200185 logger.info('Installing %r %s...', dist.name, dist.version)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200186 try:
187 _install_dist(dist, path)
188 installed_dists.append(dist)
189 except Exception as e:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200190 logger.info('Failed: %s', e)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200191
192 # reverting
193 for installed_dist in installed_dists:
Éric Araujobab50cb2011-07-29 02:37:21 +0200194 logger.info('Reverting %r', installed_dist)
Éric Araujo6f677652011-06-16 23:43:15 +0200195 remove(installed_dist.name, paths)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200196 raise e
197 return installed_dists
198
199
200def install_from_infos(install_path=None, install=[], remove=[], conflicts=[],
Éric Araujo6f677652011-06-16 23:43:15 +0200201 paths=None):
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200202 """Install and remove the given distributions.
203
204 The function signature is made to be compatible with the one of get_infos.
205 The aim of this script is to povide a way to install/remove what's asked,
206 and to rollback if needed.
207
208 So, it's not possible to be in an inconsistant state, it could be either
209 installed, either uninstalled, not half-installed.
210
211 The process follow those steps:
212
213 1. Move all distributions that will be removed in a temporary location
214 2. Install all the distributions that will be installed in a temp. loc.
215 3. If the installation fails, rollback (eg. move back) those
216 distributions, or remove what have been installed.
217 4. Else, move the distributions to the right locations, and remove for
218 real the distributions thats need to be removed.
219
220 :param install_path: the installation path where we want to install the
221 distributions.
222 :param install: list of distributions that will be installed; install_path
223 must be provided if this list is not empty.
224 :param remove: list of distributions that will be removed.
225 :param conflicts: list of conflicting distributions, eg. that will be in
226 conflict once the install and remove distribution will be
227 processed.
228 :param paths: list of paths (defaults to sys.path) to look for info
229 """
230 # first of all, if we have conflicts, stop here.
231 if conflicts:
232 raise InstallationConflict(conflicts)
233
234 if install and not install_path:
235 raise ValueError("Distributions are to be installed but `install_path`"
236 " is not provided.")
237
238 # before removing the files, we will start by moving them away
239 # then, if any error occurs, we could replace them in the good place.
240 temp_files = {} # contains lists of {dist: (old, new)} paths
241 temp_dir = None
242 if remove:
243 temp_dir = tempfile.mkdtemp()
244 for dist in remove:
245 files = dist.list_installed_files()
246 temp_files[dist] = _move_files(files, temp_dir)
247 try:
248 if install:
249 install_dists(install, install_path, paths)
250 except:
251 # if an error occurs, put back the files in the right place.
252 for files in temp_files.values():
253 for old, new in files:
254 shutil.move(new, old)
255 if temp_dir:
256 shutil.rmtree(temp_dir)
257 # now re-raising
258 raise
259
260 # we can remove them for good
261 for files in temp_files.values():
262 for old, new in files:
263 os.remove(new)
264 if temp_dir:
265 shutil.rmtree(temp_dir)
266
267
268def _get_setuptools_deps(release):
269 # NotImplementedError
270 pass
271
272
273def get_infos(requirements, index=None, installed=None, prefer_final=True):
274 """Return the informations on what's going to be installed and upgraded.
275
276 :param requirements: is a *string* containing the requirements for this
277 project (for instance "FooBar 1.1" or "BarBaz (<1.2)")
278 :param index: If an index is specified, use this one, otherwise, use
279 :class index.ClientWrapper: to get project metadatas.
280 :param installed: a list of already installed distributions.
281 :param prefer_final: when picking up the releases, prefer a "final" one
282 over a beta/alpha/etc one.
283
284 The results are returned in a dict, containing all the operations
285 needed to install the given requirements::
286
287 >>> get_install_info("FooBar (<=1.2)")
288 {'install': [<FooBar 1.1>], 'remove': [], 'conflict': []}
289
290 Conflict contains all the conflicting distributions, if there is a
291 conflict.
292 """
293 # this function does several things:
294 # 1. get a release specified by the requirements
295 # 2. gather its metadata, using setuptools compatibility if needed
296 # 3. compare this tree with what is currently installed on the system,
297 # return the requirements of what is missing
298 # 4. do that recursively and merge back the results
299 # 5. return a dict containing information about what is needed to install
300 # or remove
301
302 if not installed:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200303 logger.debug('Reading installed distributions')
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200304 installed = list(get_distributions(use_egg_info=True))
305
306 infos = {'install': [], 'remove': [], 'conflict': []}
307 # Is a compatible version of the project already installed ?
308 predicate = get_version_predicate(requirements)
309 found = False
310
311 # check that the project isn't already installed
312 for installed_project in installed:
313 # is it a compatible project ?
314 if predicate.name.lower() != installed_project.name.lower():
315 continue
316 found = True
Éric Araujobab50cb2011-07-29 02:37:21 +0200317 logger.info('Found %r %s', installed_project.name,
318 installed_project.version)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200319
320 # if we already have something installed, check it matches the
321 # requirements
Éric Araujobab50cb2011-07-29 02:37:21 +0200322 if predicate.match(installed_project.version):
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200323 return infos
324 break
325
326 if not found:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200327 logger.debug('Project not installed')
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200328
329 if not index:
330 index = wrapper.ClientWrapper()
331
332 if not installed:
333 installed = get_distributions(use_egg_info=True)
334
335 # Get all the releases that match the requirements
336 try:
337 release = index.get_release(requirements)
338 except (ReleaseNotFound, ProjectNotFound):
Éric Araujobab50cb2011-07-29 02:37:21 +0200339 raise InstallationException('Release not found: %r' % requirements)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200340
341 if release is None:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200342 logger.info('Could not find a matching project')
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200343 return infos
344
345 metadata = release.fetch_metadata()
346
347 # we need to build setuptools deps if any
348 if 'requires_dist' not in metadata:
349 metadata['requires_dist'] = _get_setuptools_deps(release)
350
351 # build the dependency graph with local and required dependencies
352 dists = list(installed)
353 dists.append(release)
354 depgraph = generate_graph(dists)
355
356 # Get what the missing deps are
357 dists = depgraph.missing[release]
358 if dists:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200359 logger.info("Missing dependencies found, retrieving metadata")
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200360 # we have missing deps
361 for dist in dists:
362 _update_infos(infos, get_infos(dist, index, installed))
363
364 # Fill in the infos
365 existing = [d for d in installed if d.name == release.name]
366 if existing:
367 infos['remove'].append(existing[0])
368 infos['conflict'].extend(depgraph.reverse_list[existing[0]])
369 infos['install'].append(release)
370 return infos
371
372
373def _update_infos(infos, new_infos):
374 """extends the lists contained in the `info` dict with those contained
375 in the `new_info` one
376 """
377 for key, value in infos.items():
378 if key in new_infos:
379 infos[key].extend(new_infos[key])
380
381
Éric Araujo6f677652011-06-16 23:43:15 +0200382def remove(project_name, paths=None, auto_confirm=True):
Tarek Ziadef47fa582011-05-30 23:26:51 +0200383 """Removes a single project from the installation.
384
385 Returns True on success
386 """
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200387 dist = get_distribution(project_name, use_egg_info=True, paths=paths)
388 if dist is None:
Éric Araujobab50cb2011-07-29 02:37:21 +0200389 raise PackagingError('Distribution %r not found' % project_name)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200390 files = dist.list_installed_files(local=True)
391 rmdirs = []
392 rmfiles = []
393 tmp = tempfile.mkdtemp(prefix=project_name + '-uninstall')
Tarek Ziadef47fa582011-05-30 23:26:51 +0200394
395 def _move_file(source, target):
396 try:
397 os.rename(source, target)
398 except OSError as err:
399 return err
400 return None
401
402 success = True
403 error = None
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200404 try:
405 for file_, md5, size in files:
406 if os.path.isfile(file_):
407 dirname, filename = os.path.split(file_)
408 tmpfile = os.path.join(tmp, filename)
409 try:
Tarek Ziadef47fa582011-05-30 23:26:51 +0200410 error = _move_file(file_, tmpfile)
411 if error is not None:
412 success = False
413 break
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200414 finally:
415 if not os.path.isfile(file_):
416 os.rename(tmpfile, file_)
417 if file_ not in rmfiles:
418 rmfiles.append(file_)
419 if dirname not in rmdirs:
420 rmdirs.append(dirname)
421 finally:
422 shutil.rmtree(tmp)
423
Tarek Ziadef47fa582011-05-30 23:26:51 +0200424 if not success:
425 logger.info('%r cannot be removed.', project_name)
Éric Araujobab50cb2011-07-29 02:37:21 +0200426 logger.info('Error: %s', error)
Tarek Ziadef47fa582011-05-30 23:26:51 +0200427 return False
428
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200429 logger.info('Removing %r: ', project_name)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200430
431 for file_ in rmfiles:
432 logger.info(' %s', file_)
433
434 # Taken from the pip project
435 if auto_confirm:
436 response = 'y'
437 else:
438 response = ask('Proceed (y/n)? ', ('y', 'n'))
439
440 if response == 'y':
441 file_count = 0
442 for file_ in rmfiles:
443 os.remove(file_)
444 file_count += 1
445
446 dir_count = 0
447 for dirname in rmdirs:
448 if not os.path.exists(dirname):
449 # could
450 continue
451
452 files_count = 0
453 for root, dir, files in os.walk(dirname):
454 files_count += len(files)
455
456 if files_count > 0:
457 # XXX Warning
458 continue
459
460 # empty dirs with only empty dirs
461 if os.stat(dirname).st_mode & stat.S_IWUSR:
462 # XXX Add a callable in shutil.rmtree to count
463 # the number of deleted elements
464 shutil.rmtree(dirname)
465 dir_count += 1
466
467 # removing the top path
468 # XXX count it ?
469 if os.path.exists(dist.path):
470 shutil.rmtree(dist.path)
471
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200472 logger.info('Success: removed %d files and %d dirs',
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200473 file_count, dir_count)
474
Tarek Ziadef47fa582011-05-30 23:26:51 +0200475 return True
476
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200477
478def install(project):
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200479 """Installs a project.
480
481 Returns True on success, False on failure
482 """
Éric Araujofa6cfbc2011-06-10 18:31:40 +0200483 if is_python_build():
484 # Python would try to install into the site-packages directory under
485 # $PREFIX, but when running from an uninstalled code checkout we don't
486 # want to create directories under the installation root
487 message = ('installing third-party projects from an uninstalled '
488 'Python is not supported')
489 logger.error(message)
490 return False
491
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200492 logger.info('Checking the installation location...')
493 purelib_path = get_path('purelib')
Éric Araujofa6cfbc2011-06-10 18:31:40 +0200494
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200495 # trying to write a file there
496 try:
497 with tempfile.NamedTemporaryFile(suffix=project,
498 dir=purelib_path) as testfile:
499 testfile.write(b'test')
500 except OSError:
Éric Araujofa6cfbc2011-06-10 18:31:40 +0200501 # FIXME this should check the errno, or be removed altogether (race
502 # condition: the directory permissions could be changed between here
503 # and the actual install)
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200504 logger.info('Unable to write in "%s". Do you have the permissions ?'
505 % purelib_path)
506 return False
507
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200508 logger.info('Getting information about %r...', project)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200509 try:
510 info = get_infos(project)
511 except InstallationException:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200512 logger.info('Cound not find %r', project)
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200513 return False
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200514
515 if info['install'] == []:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200516 logger.info('Nothing to install')
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200517 return False
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200518
519 install_path = get_config_var('base')
520 try:
521 install_from_infos(install_path,
522 info['install'], info['remove'], info['conflict'])
523
524 except InstallationConflict as e:
525 if logger.isEnabledFor(logging.INFO):
Éric Araujobab50cb2011-07-29 02:37:21 +0200526 projects = ('%r %s' % (p.name, p.version) for p in e.args[0])
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200527 logger.info('%r conflicts with %s', project, ','.join(projects))
528
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200529 return True