blob: 231764579a8d65e706223a0c08c4a6b0b0d36344 [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
Tarek Ziade5a5ce382011-05-31 12:09:34 +020016from sysconfig import get_config_var, get_path
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.
45 if not defined, create a new one, using mkdtemp
46 """
47 if not destination:
48 destination = tempfile.mkdtemp()
49
50 for old in files:
Tarek Ziade4bdd9f32011-05-21 15:12:10 +020051 filename = os.path.split(old)[-1]
52 new = os.path.join(destination, filename)
Tarek Ziade1231a4e2011-05-19 13:07:25 +020053 # try to make the paths.
54 try:
55 os.makedirs(os.path.dirname(new))
56 except OSError as e:
57 if e.errno == errno.EEXIST:
58 pass
59 else:
60 raise e
61 os.rename(old, new)
62 yield old, new
63
64
65def _run_distutils_install(path):
66 # backward compat: using setuptools or plain-distutils
67 cmd = '%s setup.py install --record=%s'
68 record_file = os.path.join(path, 'RECORD')
69 os.system(cmd % (sys.executable, record_file))
70 if not os.path.exists(record_file):
71 raise ValueError('failed to install')
72 else:
73 egginfo_to_distinfo(record_file, remove_egginfo=True)
74
75
76def _run_setuptools_install(path):
77 cmd = '%s setup.py install --record=%s --single-version-externally-managed'
78 record_file = os.path.join(path, 'RECORD')
Tarek Ziade5a5ce382011-05-31 12:09:34 +020079
Tarek Ziade1231a4e2011-05-19 13:07:25 +020080 os.system(cmd % (sys.executable, record_file))
81 if not os.path.exists(record_file):
82 raise ValueError('failed to install')
83 else:
84 egginfo_to_distinfo(record_file, remove_egginfo=True)
85
86
87def _run_packaging_install(path):
88 # XXX check for a valid setup.cfg?
89 dist = Distribution()
90 dist.parse_config_files()
91 try:
92 dist.run_command('install_dist')
Tarek Ziade5a5ce382011-05-31 12:09:34 +020093 name = dist.metadata['name']
94 return database.get_distribution(name) is not None
Tarek Ziade1231a4e2011-05-19 13:07:25 +020095 except (IOError, os.error, PackagingError, CCompilerError) as msg:
Tarek Ziade5a5ce382011-05-31 12:09:34 +020096 raise ValueError("Failed to install, " + str(msg))
Tarek Ziade1231a4e2011-05-19 13:07:25 +020097
98
99def _install_dist(dist, path):
100 """Install a distribution into a path.
101
102 This:
103
104 * unpack the distribution
105 * copy the files in "path"
106 * determine if the distribution is packaging or distutils1.
107 """
108 where = dist.unpack()
109
110 if where is None:
111 raise ValueError('Cannot locate the unpacked archive')
112
113 return _run_install_from_archive(where)
114
115
116def install_local_project(path):
117 """Install a distribution from a source directory.
118
119 If the source directory contains a setup.py install using distutils1.
120 If a setup.cfg is found, install using the install_dist command.
121
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200122 Returns True on success, False on Failure.
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200123 """
124 path = os.path.abspath(path)
125 if os.path.isdir(path):
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200126 logger.info('Installing from source directory: %s', path)
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200127 return _run_install_from_dir(path)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200128 elif _is_archive_file(path):
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200129 logger.info('Installing from archive: %s', path)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200130 _unpacked_dir = tempfile.mkdtemp()
131 shutil.unpack_archive(path, _unpacked_dir)
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200132 return _run_install_from_archive(_unpacked_dir)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200133 else:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200134 logger.warning('No projects to install.')
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200135 return False
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200136
137
138def _run_install_from_archive(source_dir):
139 # XXX need a better way
140 for item in os.listdir(source_dir):
141 fullpath = os.path.join(source_dir, item)
142 if os.path.isdir(fullpath):
143 source_dir = fullpath
144 break
145 return _run_install_from_dir(source_dir)
146
147
148install_methods = {
149 'packaging': _run_packaging_install,
150 'setuptools': _run_setuptools_install,
151 'distutils': _run_distutils_install}
152
153
154def _run_install_from_dir(source_dir):
155 old_dir = os.getcwd()
156 os.chdir(source_dir)
157 install_method = get_install_method(source_dir)
158 func = install_methods[install_method]
159 try:
160 func = install_methods[install_method]
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200161 try:
162 func(source_dir)
163 return True
164 except ValueError as err:
165 # failed to install
166 logger.info(str(err))
167 return False
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200168 finally:
169 os.chdir(old_dir)
170
171
172def install_dists(dists, path, paths=sys.path):
173 """Install all distributions provided in dists, with the given prefix.
174
175 If an error occurs while installing one of the distributions, uninstall all
176 the installed distribution (in the context if this function).
177
178 Return a list of installed dists.
179
180 :param dists: distributions to install
181 :param path: base path to install distribution in
182 :param paths: list of paths (defaults to sys.path) to look for info
183 """
184 if not path:
185 path = tempfile.mkdtemp()
186
187 installed_dists = []
188 for dist in dists:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200189 logger.info('Installing %r %s...', dist.name, dist.version)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200190 try:
191 _install_dist(dist, path)
192 installed_dists.append(dist)
193 except Exception as e:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200194 logger.info('Failed: %s', e)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200195
196 # reverting
197 for installed_dist in installed_dists:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200198 logger.info('Reverting %s', installed_dist)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200199 _remove_dist(installed_dist, paths)
200 raise e
201 return installed_dists
202
203
204def install_from_infos(install_path=None, install=[], remove=[], conflicts=[],
205 paths=sys.path):
206 """Install and remove the given distributions.
207
208 The function signature is made to be compatible with the one of get_infos.
209 The aim of this script is to povide a way to install/remove what's asked,
210 and to rollback if needed.
211
212 So, it's not possible to be in an inconsistant state, it could be either
213 installed, either uninstalled, not half-installed.
214
215 The process follow those steps:
216
217 1. Move all distributions that will be removed in a temporary location
218 2. Install all the distributions that will be installed in a temp. loc.
219 3. If the installation fails, rollback (eg. move back) those
220 distributions, or remove what have been installed.
221 4. Else, move the distributions to the right locations, and remove for
222 real the distributions thats need to be removed.
223
224 :param install_path: the installation path where we want to install the
225 distributions.
226 :param install: list of distributions that will be installed; install_path
227 must be provided if this list is not empty.
228 :param remove: list of distributions that will be removed.
229 :param conflicts: list of conflicting distributions, eg. that will be in
230 conflict once the install and remove distribution will be
231 processed.
232 :param paths: list of paths (defaults to sys.path) to look for info
233 """
234 # first of all, if we have conflicts, stop here.
235 if conflicts:
236 raise InstallationConflict(conflicts)
237
238 if install and not install_path:
239 raise ValueError("Distributions are to be installed but `install_path`"
240 " is not provided.")
241
242 # before removing the files, we will start by moving them away
243 # then, if any error occurs, we could replace them in the good place.
244 temp_files = {} # contains lists of {dist: (old, new)} paths
245 temp_dir = None
246 if remove:
247 temp_dir = tempfile.mkdtemp()
248 for dist in remove:
249 files = dist.list_installed_files()
250 temp_files[dist] = _move_files(files, temp_dir)
251 try:
252 if install:
253 install_dists(install, install_path, paths)
254 except:
255 # if an error occurs, put back the files in the right place.
256 for files in temp_files.values():
257 for old, new in files:
258 shutil.move(new, old)
259 if temp_dir:
260 shutil.rmtree(temp_dir)
261 # now re-raising
262 raise
263
264 # we can remove them for good
265 for files in temp_files.values():
266 for old, new in files:
267 os.remove(new)
268 if temp_dir:
269 shutil.rmtree(temp_dir)
270
271
272def _get_setuptools_deps(release):
273 # NotImplementedError
274 pass
275
276
277def get_infos(requirements, index=None, installed=None, prefer_final=True):
278 """Return the informations on what's going to be installed and upgraded.
279
280 :param requirements: is a *string* containing the requirements for this
281 project (for instance "FooBar 1.1" or "BarBaz (<1.2)")
282 :param index: If an index is specified, use this one, otherwise, use
283 :class index.ClientWrapper: to get project metadatas.
284 :param installed: a list of already installed distributions.
285 :param prefer_final: when picking up the releases, prefer a "final" one
286 over a beta/alpha/etc one.
287
288 The results are returned in a dict, containing all the operations
289 needed to install the given requirements::
290
291 >>> get_install_info("FooBar (<=1.2)")
292 {'install': [<FooBar 1.1>], 'remove': [], 'conflict': []}
293
294 Conflict contains all the conflicting distributions, if there is a
295 conflict.
296 """
297 # this function does several things:
298 # 1. get a release specified by the requirements
299 # 2. gather its metadata, using setuptools compatibility if needed
300 # 3. compare this tree with what is currently installed on the system,
301 # return the requirements of what is missing
302 # 4. do that recursively and merge back the results
303 # 5. return a dict containing information about what is needed to install
304 # or remove
305
306 if not installed:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200307 logger.debug('Reading installed distributions')
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200308 installed = list(get_distributions(use_egg_info=True))
309
310 infos = {'install': [], 'remove': [], 'conflict': []}
311 # Is a compatible version of the project already installed ?
312 predicate = get_version_predicate(requirements)
313 found = False
314
315 # check that the project isn't already installed
316 for installed_project in installed:
317 # is it a compatible project ?
318 if predicate.name.lower() != installed_project.name.lower():
319 continue
320 found = True
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200321 logger.info('Found %s %s', installed_project.name,
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200322 installed_project.metadata['version'])
323
324 # if we already have something installed, check it matches the
325 # requirements
326 if predicate.match(installed_project.metadata['version']):
327 return infos
328 break
329
330 if not found:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200331 logger.debug('Project not installed')
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200332
333 if not index:
334 index = wrapper.ClientWrapper()
335
336 if not installed:
337 installed = get_distributions(use_egg_info=True)
338
339 # Get all the releases that match the requirements
340 try:
341 release = index.get_release(requirements)
342 except (ReleaseNotFound, ProjectNotFound):
343 raise InstallationException('Release not found: "%s"' % requirements)
344
345 if release is None:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200346 logger.info('Could not find a matching project')
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200347 return infos
348
349 metadata = release.fetch_metadata()
350
351 # we need to build setuptools deps if any
352 if 'requires_dist' not in metadata:
353 metadata['requires_dist'] = _get_setuptools_deps(release)
354
355 # build the dependency graph with local and required dependencies
356 dists = list(installed)
357 dists.append(release)
358 depgraph = generate_graph(dists)
359
360 # Get what the missing deps are
361 dists = depgraph.missing[release]
362 if dists:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200363 logger.info("Missing dependencies found, retrieving metadata")
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200364 # we have missing deps
365 for dist in dists:
366 _update_infos(infos, get_infos(dist, index, installed))
367
368 # Fill in the infos
369 existing = [d for d in installed if d.name == release.name]
370 if existing:
371 infos['remove'].append(existing[0])
372 infos['conflict'].extend(depgraph.reverse_list[existing[0]])
373 infos['install'].append(release)
374 return infos
375
376
377def _update_infos(infos, new_infos):
378 """extends the lists contained in the `info` dict with those contained
379 in the `new_info` one
380 """
381 for key, value in infos.items():
382 if key in new_infos:
383 infos[key].extend(new_infos[key])
384
385
386def _remove_dist(dist, paths=sys.path):
387 remove(dist.name, paths)
388
389
390def remove(project_name, paths=sys.path, auto_confirm=True):
Tarek Ziadef47fa582011-05-30 23:26:51 +0200391 """Removes a single project from the installation.
392
393 Returns True on success
394 """
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200395 dist = get_distribution(project_name, use_egg_info=True, paths=paths)
396 if dist is None:
397 raise PackagingError('Distribution "%s" not found' % project_name)
398 files = dist.list_installed_files(local=True)
399 rmdirs = []
400 rmfiles = []
401 tmp = tempfile.mkdtemp(prefix=project_name + '-uninstall')
Tarek Ziadef47fa582011-05-30 23:26:51 +0200402
403 def _move_file(source, target):
404 try:
405 os.rename(source, target)
406 except OSError as err:
407 return err
408 return None
409
410 success = True
411 error = None
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200412 try:
413 for file_, md5, size in files:
414 if os.path.isfile(file_):
415 dirname, filename = os.path.split(file_)
416 tmpfile = os.path.join(tmp, filename)
417 try:
Tarek Ziadef47fa582011-05-30 23:26:51 +0200418 error = _move_file(file_, tmpfile)
419 if error is not None:
420 success = False
421 break
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200422 finally:
423 if not os.path.isfile(file_):
424 os.rename(tmpfile, file_)
425 if file_ not in rmfiles:
426 rmfiles.append(file_)
427 if dirname not in rmdirs:
428 rmdirs.append(dirname)
429 finally:
430 shutil.rmtree(tmp)
431
Tarek Ziadef47fa582011-05-30 23:26:51 +0200432 if not success:
433 logger.info('%r cannot be removed.', project_name)
434 logger.info('Error: %s' % str(error))
435 return False
436
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200437 logger.info('Removing %r: ', project_name)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200438
439 for file_ in rmfiles:
440 logger.info(' %s', file_)
441
442 # Taken from the pip project
443 if auto_confirm:
444 response = 'y'
445 else:
446 response = ask('Proceed (y/n)? ', ('y', 'n'))
447
448 if response == 'y':
449 file_count = 0
450 for file_ in rmfiles:
451 os.remove(file_)
452 file_count += 1
453
454 dir_count = 0
455 for dirname in rmdirs:
456 if not os.path.exists(dirname):
457 # could
458 continue
459
460 files_count = 0
461 for root, dir, files in os.walk(dirname):
462 files_count += len(files)
463
464 if files_count > 0:
465 # XXX Warning
466 continue
467
468 # empty dirs with only empty dirs
469 if os.stat(dirname).st_mode & stat.S_IWUSR:
470 # XXX Add a callable in shutil.rmtree to count
471 # the number of deleted elements
472 shutil.rmtree(dirname)
473 dir_count += 1
474
475 # removing the top path
476 # XXX count it ?
477 if os.path.exists(dist.path):
478 shutil.rmtree(dist.path)
479
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200480 logger.info('Success: removed %d files and %d dirs',
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200481 file_count, dir_count)
482
Tarek Ziadef47fa582011-05-30 23:26:51 +0200483 return True
484
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200485
486def install(project):
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200487 """Installs a project.
488
489 Returns True on success, False on failure
490 """
491 logger.info('Checking the installation location...')
492 purelib_path = get_path('purelib')
493 # trying to write a file there
494 try:
495 with tempfile.NamedTemporaryFile(suffix=project,
496 dir=purelib_path) as testfile:
497 testfile.write(b'test')
498 except OSError:
499 # was unable to write a file
500 logger.info('Unable to write in "%s". Do you have the permissions ?'
501 % purelib_path)
502 return False
503
504
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200505 logger.info('Getting information about %r...', project)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200506 try:
507 info = get_infos(project)
508 except InstallationException:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200509 logger.info('Cound not find %r', project)
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200510 return False
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200511
512 if info['install'] == []:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200513 logger.info('Nothing to install')
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200514 return False
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200515
516 install_path = get_config_var('base')
517 try:
518 install_from_infos(install_path,
519 info['install'], info['remove'], info['conflict'])
520
521 except InstallationConflict as e:
522 if logger.isEnabledFor(logging.INFO):
Éric Araujo5c6684f2011-06-10 03:10:53 +0200523 projects = ['%r %s' % (p.name, p.version) for p in e.args[0]]
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200524 logger.info('%r conflicts with %s', project, ','.join(projects))
525
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200526 return True
527
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200528
529def _main(**attrs):
530 if 'script_args' not in attrs:
531 import sys
532 attrs['requirements'] = sys.argv[1]
533 get_infos(**attrs)
534
535if __name__ == '__main__':
536 _main()