blob: 551ece1ea48fb7610c5cadafcc5b7d7a2f146de6 [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.
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:
Éric Araujo6f677652011-06-16 23:43:15 +020057 if e.errno != errno.EEXIST:
58 raise
Tarek Ziade1231a4e2011-05-19 13:07:25 +020059 os.rename(old, new)
60 yield old, new
61
62
63def _run_distutils_install(path):
64 # backward compat: using setuptools or plain-distutils
65 cmd = '%s setup.py install --record=%s'
66 record_file = os.path.join(path, 'RECORD')
67 os.system(cmd % (sys.executable, record_file))
68 if not os.path.exists(record_file):
69 raise ValueError('failed to install')
70 else:
71 egginfo_to_distinfo(record_file, remove_egginfo=True)
72
73
74def _run_setuptools_install(path):
75 cmd = '%s setup.py install --record=%s --single-version-externally-managed'
76 record_file = os.path.join(path, 'RECORD')
Tarek Ziade5a5ce382011-05-31 12:09:34 +020077
Tarek Ziade1231a4e2011-05-19 13:07:25 +020078 os.system(cmd % (sys.executable, record_file))
79 if not os.path.exists(record_file):
80 raise ValueError('failed to install')
81 else:
82 egginfo_to_distinfo(record_file, remove_egginfo=True)
83
84
85def _run_packaging_install(path):
86 # XXX check for a valid setup.cfg?
87 dist = Distribution()
88 dist.parse_config_files()
89 try:
90 dist.run_command('install_dist')
Tarek Ziade5a5ce382011-05-31 12:09:34 +020091 name = dist.metadata['name']
92 return database.get_distribution(name) is not None
Tarek Ziade1231a4e2011-05-19 13:07:25 +020093 except (IOError, os.error, PackagingError, CCompilerError) as msg:
Tarek Ziade5a5ce382011-05-31 12:09:34 +020094 raise ValueError("Failed to install, " + str(msg))
Tarek Ziade1231a4e2011-05-19 13:07:25 +020095
96
97def _install_dist(dist, path):
98 """Install a distribution into a path.
99
100 This:
101
102 * unpack the distribution
103 * copy the files in "path"
104 * determine if the distribution is packaging or distutils1.
105 """
106 where = dist.unpack()
107
108 if where is None:
109 raise ValueError('Cannot locate the unpacked archive')
110
111 return _run_install_from_archive(where)
112
113
114def install_local_project(path):
115 """Install a distribution from a source directory.
116
117 If the source directory contains a setup.py install using distutils1.
118 If a setup.cfg is found, install using the install_dist command.
119
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200120 Returns True on success, False on Failure.
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200121 """
122 path = os.path.abspath(path)
123 if os.path.isdir(path):
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200124 logger.info('Installing from source directory: %s', path)
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200125 return _run_install_from_dir(path)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200126 elif _is_archive_file(path):
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200127 logger.info('Installing from archive: %s', path)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200128 _unpacked_dir = tempfile.mkdtemp()
129 shutil.unpack_archive(path, _unpacked_dir)
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200130 return _run_install_from_archive(_unpacked_dir)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200131 else:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200132 logger.warning('No projects 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 """
182 if not path:
183 path = tempfile.mkdtemp()
184
185 installed_dists = []
186 for dist in dists:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200187 logger.info('Installing %r %s...', dist.name, dist.version)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200188 try:
189 _install_dist(dist, path)
190 installed_dists.append(dist)
191 except Exception as e:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200192 logger.info('Failed: %s', e)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200193
194 # reverting
195 for installed_dist in installed_dists:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200196 logger.info('Reverting %s', installed_dist)
Éric Araujo6f677652011-06-16 23:43:15 +0200197 remove(installed_dist.name, paths)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200198 raise e
199 return installed_dists
200
201
202def install_from_infos(install_path=None, install=[], remove=[], conflicts=[],
Éric Araujo6f677652011-06-16 23:43:15 +0200203 paths=None):
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200204 """Install and remove the given distributions.
205
206 The function signature is made to be compatible with the one of get_infos.
207 The aim of this script is to povide a way to install/remove what's asked,
208 and to rollback if needed.
209
210 So, it's not possible to be in an inconsistant state, it could be either
211 installed, either uninstalled, not half-installed.
212
213 The process follow those steps:
214
215 1. Move all distributions that will be removed in a temporary location
216 2. Install all the distributions that will be installed in a temp. loc.
217 3. If the installation fails, rollback (eg. move back) those
218 distributions, or remove what have been installed.
219 4. Else, move the distributions to the right locations, and remove for
220 real the distributions thats need to be removed.
221
222 :param install_path: the installation path where we want to install the
223 distributions.
224 :param install: list of distributions that will be installed; install_path
225 must be provided if this list is not empty.
226 :param remove: list of distributions that will be removed.
227 :param conflicts: list of conflicting distributions, eg. that will be in
228 conflict once the install and remove distribution will be
229 processed.
230 :param paths: list of paths (defaults to sys.path) to look for info
231 """
232 # first of all, if we have conflicts, stop here.
233 if conflicts:
234 raise InstallationConflict(conflicts)
235
236 if install and not install_path:
237 raise ValueError("Distributions are to be installed but `install_path`"
238 " is not provided.")
239
240 # before removing the files, we will start by moving them away
241 # then, if any error occurs, we could replace them in the good place.
242 temp_files = {} # contains lists of {dist: (old, new)} paths
243 temp_dir = None
244 if remove:
245 temp_dir = tempfile.mkdtemp()
246 for dist in remove:
247 files = dist.list_installed_files()
248 temp_files[dist] = _move_files(files, temp_dir)
249 try:
250 if install:
251 install_dists(install, install_path, paths)
252 except:
253 # if an error occurs, put back the files in the right place.
254 for files in temp_files.values():
255 for old, new in files:
256 shutil.move(new, old)
257 if temp_dir:
258 shutil.rmtree(temp_dir)
259 # now re-raising
260 raise
261
262 # we can remove them for good
263 for files in temp_files.values():
264 for old, new in files:
265 os.remove(new)
266 if temp_dir:
267 shutil.rmtree(temp_dir)
268
269
270def _get_setuptools_deps(release):
271 # NotImplementedError
272 pass
273
274
275def get_infos(requirements, index=None, installed=None, prefer_final=True):
276 """Return the informations on what's going to be installed and upgraded.
277
278 :param requirements: is a *string* containing the requirements for this
279 project (for instance "FooBar 1.1" or "BarBaz (<1.2)")
280 :param index: If an index is specified, use this one, otherwise, use
281 :class index.ClientWrapper: to get project metadatas.
282 :param installed: a list of already installed distributions.
283 :param prefer_final: when picking up the releases, prefer a "final" one
284 over a beta/alpha/etc one.
285
286 The results are returned in a dict, containing all the operations
287 needed to install the given requirements::
288
289 >>> get_install_info("FooBar (<=1.2)")
290 {'install': [<FooBar 1.1>], 'remove': [], 'conflict': []}
291
292 Conflict contains all the conflicting distributions, if there is a
293 conflict.
294 """
295 # this function does several things:
296 # 1. get a release specified by the requirements
297 # 2. gather its metadata, using setuptools compatibility if needed
298 # 3. compare this tree with what is currently installed on the system,
299 # return the requirements of what is missing
300 # 4. do that recursively and merge back the results
301 # 5. return a dict containing information about what is needed to install
302 # or remove
303
304 if not installed:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200305 logger.debug('Reading installed distributions')
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200306 installed = list(get_distributions(use_egg_info=True))
307
308 infos = {'install': [], 'remove': [], 'conflict': []}
309 # Is a compatible version of the project already installed ?
310 predicate = get_version_predicate(requirements)
311 found = False
312
313 # check that the project isn't already installed
314 for installed_project in installed:
315 # is it a compatible project ?
316 if predicate.name.lower() != installed_project.name.lower():
317 continue
318 found = True
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200319 logger.info('Found %s %s', installed_project.name,
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200320 installed_project.metadata['version'])
321
322 # if we already have something installed, check it matches the
323 # requirements
324 if predicate.match(installed_project.metadata['version']):
325 return infos
326 break
327
328 if not found:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200329 logger.debug('Project not installed')
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200330
331 if not index:
332 index = wrapper.ClientWrapper()
333
334 if not installed:
335 installed = get_distributions(use_egg_info=True)
336
337 # Get all the releases that match the requirements
338 try:
339 release = index.get_release(requirements)
340 except (ReleaseNotFound, ProjectNotFound):
341 raise InstallationException('Release not found: "%s"' % requirements)
342
343 if release is None:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200344 logger.info('Could not find a matching project')
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200345 return infos
346
347 metadata = release.fetch_metadata()
348
349 # we need to build setuptools deps if any
350 if 'requires_dist' not in metadata:
351 metadata['requires_dist'] = _get_setuptools_deps(release)
352
353 # build the dependency graph with local and required dependencies
354 dists = list(installed)
355 dists.append(release)
356 depgraph = generate_graph(dists)
357
358 # Get what the missing deps are
359 dists = depgraph.missing[release]
360 if dists:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200361 logger.info("Missing dependencies found, retrieving metadata")
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200362 # we have missing deps
363 for dist in dists:
364 _update_infos(infos, get_infos(dist, index, installed))
365
366 # Fill in the infos
367 existing = [d for d in installed if d.name == release.name]
368 if existing:
369 infos['remove'].append(existing[0])
370 infos['conflict'].extend(depgraph.reverse_list[existing[0]])
371 infos['install'].append(release)
372 return infos
373
374
375def _update_infos(infos, new_infos):
376 """extends the lists contained in the `info` dict with those contained
377 in the `new_info` one
378 """
379 for key, value in infos.items():
380 if key in new_infos:
381 infos[key].extend(new_infos[key])
382
383
Éric Araujo6f677652011-06-16 23:43:15 +0200384def remove(project_name, paths=None, auto_confirm=True):
Tarek Ziadef47fa582011-05-30 23:26:51 +0200385 """Removes a single project from the installation.
386
387 Returns True on success
388 """
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200389 dist = get_distribution(project_name, use_egg_info=True, paths=paths)
390 if dist is None:
391 raise PackagingError('Distribution "%s" not found' % project_name)
392 files = dist.list_installed_files(local=True)
393 rmdirs = []
394 rmfiles = []
395 tmp = tempfile.mkdtemp(prefix=project_name + '-uninstall')
Tarek Ziadef47fa582011-05-30 23:26:51 +0200396
397 def _move_file(source, target):
398 try:
399 os.rename(source, target)
400 except OSError as err:
401 return err
402 return None
403
404 success = True
405 error = None
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200406 try:
407 for file_, md5, size in files:
408 if os.path.isfile(file_):
409 dirname, filename = os.path.split(file_)
410 tmpfile = os.path.join(tmp, filename)
411 try:
Tarek Ziadef47fa582011-05-30 23:26:51 +0200412 error = _move_file(file_, tmpfile)
413 if error is not None:
414 success = False
415 break
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200416 finally:
417 if not os.path.isfile(file_):
418 os.rename(tmpfile, file_)
419 if file_ not in rmfiles:
420 rmfiles.append(file_)
421 if dirname not in rmdirs:
422 rmdirs.append(dirname)
423 finally:
424 shutil.rmtree(tmp)
425
Tarek Ziadef47fa582011-05-30 23:26:51 +0200426 if not success:
427 logger.info('%r cannot be removed.', project_name)
428 logger.info('Error: %s' % str(error))
429 return False
430
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200431 logger.info('Removing %r: ', project_name)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200432
433 for file_ in rmfiles:
434 logger.info(' %s', file_)
435
436 # Taken from the pip project
437 if auto_confirm:
438 response = 'y'
439 else:
440 response = ask('Proceed (y/n)? ', ('y', 'n'))
441
442 if response == 'y':
443 file_count = 0
444 for file_ in rmfiles:
445 os.remove(file_)
446 file_count += 1
447
448 dir_count = 0
449 for dirname in rmdirs:
450 if not os.path.exists(dirname):
451 # could
452 continue
453
454 files_count = 0
455 for root, dir, files in os.walk(dirname):
456 files_count += len(files)
457
458 if files_count > 0:
459 # XXX Warning
460 continue
461
462 # empty dirs with only empty dirs
463 if os.stat(dirname).st_mode & stat.S_IWUSR:
464 # XXX Add a callable in shutil.rmtree to count
465 # the number of deleted elements
466 shutil.rmtree(dirname)
467 dir_count += 1
468
469 # removing the top path
470 # XXX count it ?
471 if os.path.exists(dist.path):
472 shutil.rmtree(dist.path)
473
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200474 logger.info('Success: removed %d files and %d dirs',
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200475 file_count, dir_count)
476
Tarek Ziadef47fa582011-05-30 23:26:51 +0200477 return True
478
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200479
480def install(project):
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200481 """Installs a project.
482
483 Returns True on success, False on failure
484 """
Éric Araujofa6cfbc2011-06-10 18:31:40 +0200485 if is_python_build():
486 # Python would try to install into the site-packages directory under
487 # $PREFIX, but when running from an uninstalled code checkout we don't
488 # want to create directories under the installation root
489 message = ('installing third-party projects from an uninstalled '
490 'Python is not supported')
491 logger.error(message)
492 return False
493
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200494 logger.info('Checking the installation location...')
495 purelib_path = get_path('purelib')
Éric Araujofa6cfbc2011-06-10 18:31:40 +0200496
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200497 # trying to write a file there
498 try:
499 with tempfile.NamedTemporaryFile(suffix=project,
500 dir=purelib_path) as testfile:
501 testfile.write(b'test')
502 except OSError:
Éric Araujofa6cfbc2011-06-10 18:31:40 +0200503 # FIXME this should check the errno, or be removed altogether (race
504 # condition: the directory permissions could be changed between here
505 # and the actual install)
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200506 logger.info('Unable to write in "%s". Do you have the permissions ?'
507 % purelib_path)
508 return False
509
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200510 logger.info('Getting information about %r...', project)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200511 try:
512 info = get_infos(project)
513 except InstallationException:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200514 logger.info('Cound not find %r', project)
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200515 return False
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200516
517 if info['install'] == []:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200518 logger.info('Nothing to install')
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200519 return False
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200520
521 install_path = get_config_var('base')
522 try:
523 install_from_infos(install_path,
524 info['install'], info['remove'], info['conflict'])
525
526 except InstallationConflict as e:
527 if logger.isEnabledFor(logging.INFO):
Éric Araujo5c6684f2011-06-10 03:10:53 +0200528 projects = ['%r %s' % (p.name, p.version) for p in e.args[0]]
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200529 logger.info('%r conflicts with %s', project, ','.join(projects))
530
Tarek Ziade5a5ce382011-05-31 12:09:34 +0200531 return True
532
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200533
534def _main(**attrs):
535 if 'script_args' not in attrs:
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200536 attrs['requirements'] = sys.argv[1]
537 get_infos(**attrs)
538
539if __name__ == '__main__':
540 _main()