blob: 9f945329dc797d8a16bf0d8a97afd5fdd6fb2051 [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"""
9
10import os
11import sys
12import stat
13import errno
14import shutil
15import logging
16import tempfile
17from sysconfig import get_config_var
18
19from packaging import logger
20from packaging.dist import Distribution
21from packaging.util import (_is_archive_file, ask, get_install_method,
22 egginfo_to_distinfo)
23from packaging.pypi import wrapper
24from packaging.version import get_version_predicate
25from packaging.database import get_distributions, get_distribution
26from packaging.depgraph import generate_graph
27
28from packaging.errors import (PackagingError, InstallationException,
29 InstallationConflict, CCompilerError)
30from packaging.pypi.errors import ProjectNotFound, ReleaseNotFound
31
32__all__ = ['install_dists', 'install_from_infos', 'get_infos', 'remove',
33 'install', 'install_local_project']
34
35
36def _move_files(files, destination):
37 """Move the list of files in the destination folder, keeping the same
38 structure.
39
40 Return a list of tuple (old, new) emplacement of files
41
42 :param files: a list of files to move.
43 :param destination: the destination directory to put on the files.
44 if not defined, create a new one, using mkdtemp
45 """
46 if not destination:
47 destination = tempfile.mkdtemp()
48
49 for old in files:
Tarek Ziade4bdd9f32011-05-21 15:12:10 +020050 filename = os.path.split(old)[-1]
51 new = os.path.join(destination, filename)
Tarek Ziade1231a4e2011-05-19 13:07:25 +020052 # try to make the paths.
53 try:
54 os.makedirs(os.path.dirname(new))
55 except OSError as e:
56 if e.errno == errno.EEXIST:
57 pass
58 else:
59 raise e
60 os.rename(old, new)
61 yield old, new
62
63
64def _run_distutils_install(path):
65 # backward compat: using setuptools or plain-distutils
66 cmd = '%s setup.py install --record=%s'
67 record_file = os.path.join(path, 'RECORD')
68 os.system(cmd % (sys.executable, record_file))
69 if not os.path.exists(record_file):
70 raise ValueError('failed to install')
71 else:
72 egginfo_to_distinfo(record_file, remove_egginfo=True)
73
74
75def _run_setuptools_install(path):
76 cmd = '%s setup.py install --record=%s --single-version-externally-managed'
77 record_file = os.path.join(path, 'RECORD')
78 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')
91 except (IOError, os.error, PackagingError, CCompilerError) as msg:
92 raise SystemExit("error: " + str(msg))
93
94
95def _install_dist(dist, path):
96 """Install a distribution into a path.
97
98 This:
99
100 * unpack the distribution
101 * copy the files in "path"
102 * determine if the distribution is packaging or distutils1.
103 """
104 where = dist.unpack()
105
106 if where is None:
107 raise ValueError('Cannot locate the unpacked archive')
108
109 return _run_install_from_archive(where)
110
111
112def install_local_project(path):
113 """Install a distribution from a source directory.
114
115 If the source directory contains a setup.py install using distutils1.
116 If a setup.cfg is found, install using the install_dist command.
117
118 """
119 path = os.path.abspath(path)
120 if os.path.isdir(path):
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200121 logger.info('Installing from source directory: %s', path)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200122 _run_install_from_dir(path)
123 elif _is_archive_file(path):
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200124 logger.info('Installing from archive: %s', path)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200125 _unpacked_dir = tempfile.mkdtemp()
126 shutil.unpack_archive(path, _unpacked_dir)
127 _run_install_from_archive(_unpacked_dir)
128 else:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200129 logger.warning('No projects to install.')
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200130
131
132def _run_install_from_archive(source_dir):
133 # XXX need a better way
134 for item in os.listdir(source_dir):
135 fullpath = os.path.join(source_dir, item)
136 if os.path.isdir(fullpath):
137 source_dir = fullpath
138 break
139 return _run_install_from_dir(source_dir)
140
141
142install_methods = {
143 'packaging': _run_packaging_install,
144 'setuptools': _run_setuptools_install,
145 'distutils': _run_distutils_install}
146
147
148def _run_install_from_dir(source_dir):
149 old_dir = os.getcwd()
150 os.chdir(source_dir)
151 install_method = get_install_method(source_dir)
152 func = install_methods[install_method]
153 try:
154 func = install_methods[install_method]
155 return func(source_dir)
156 finally:
157 os.chdir(old_dir)
158
159
160def install_dists(dists, path, paths=sys.path):
161 """Install all distributions provided in dists, with the given prefix.
162
163 If an error occurs while installing one of the distributions, uninstall all
164 the installed distribution (in the context if this function).
165
166 Return a list of installed dists.
167
168 :param dists: distributions to install
169 :param path: base path to install distribution in
170 :param paths: list of paths (defaults to sys.path) to look for info
171 """
172 if not path:
173 path = tempfile.mkdtemp()
174
175 installed_dists = []
176 for dist in dists:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200177 logger.info('Installing %r %s...', dist.name, dist.version)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200178 try:
179 _install_dist(dist, path)
180 installed_dists.append(dist)
181 except Exception as e:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200182 logger.info('Failed: %s', e)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200183
184 # reverting
185 for installed_dist in installed_dists:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200186 logger.info('Reverting %s', installed_dist)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200187 _remove_dist(installed_dist, paths)
188 raise e
189 return installed_dists
190
191
192def install_from_infos(install_path=None, install=[], remove=[], conflicts=[],
193 paths=sys.path):
194 """Install and remove the given distributions.
195
196 The function signature is made to be compatible with the one of get_infos.
197 The aim of this script is to povide a way to install/remove what's asked,
198 and to rollback if needed.
199
200 So, it's not possible to be in an inconsistant state, it could be either
201 installed, either uninstalled, not half-installed.
202
203 The process follow those steps:
204
205 1. Move all distributions that will be removed in a temporary location
206 2. Install all the distributions that will be installed in a temp. loc.
207 3. If the installation fails, rollback (eg. move back) those
208 distributions, or remove what have been installed.
209 4. Else, move the distributions to the right locations, and remove for
210 real the distributions thats need to be removed.
211
212 :param install_path: the installation path where we want to install the
213 distributions.
214 :param install: list of distributions that will be installed; install_path
215 must be provided if this list is not empty.
216 :param remove: list of distributions that will be removed.
217 :param conflicts: list of conflicting distributions, eg. that will be in
218 conflict once the install and remove distribution will be
219 processed.
220 :param paths: list of paths (defaults to sys.path) to look for info
221 """
222 # first of all, if we have conflicts, stop here.
223 if conflicts:
224 raise InstallationConflict(conflicts)
225
226 if install and not install_path:
227 raise ValueError("Distributions are to be installed but `install_path`"
228 " is not provided.")
229
230 # before removing the files, we will start by moving them away
231 # then, if any error occurs, we could replace them in the good place.
232 temp_files = {} # contains lists of {dist: (old, new)} paths
233 temp_dir = None
234 if remove:
235 temp_dir = tempfile.mkdtemp()
236 for dist in remove:
237 files = dist.list_installed_files()
238 temp_files[dist] = _move_files(files, temp_dir)
239 try:
240 if install:
241 install_dists(install, install_path, paths)
242 except:
243 # if an error occurs, put back the files in the right place.
244 for files in temp_files.values():
245 for old, new in files:
246 shutil.move(new, old)
247 if temp_dir:
248 shutil.rmtree(temp_dir)
249 # now re-raising
250 raise
251
252 # we can remove them for good
253 for files in temp_files.values():
254 for old, new in files:
255 os.remove(new)
256 if temp_dir:
257 shutil.rmtree(temp_dir)
258
259
260def _get_setuptools_deps(release):
261 # NotImplementedError
262 pass
263
264
265def get_infos(requirements, index=None, installed=None, prefer_final=True):
266 """Return the informations on what's going to be installed and upgraded.
267
268 :param requirements: is a *string* containing the requirements for this
269 project (for instance "FooBar 1.1" or "BarBaz (<1.2)")
270 :param index: If an index is specified, use this one, otherwise, use
271 :class index.ClientWrapper: to get project metadatas.
272 :param installed: a list of already installed distributions.
273 :param prefer_final: when picking up the releases, prefer a "final" one
274 over a beta/alpha/etc one.
275
276 The results are returned in a dict, containing all the operations
277 needed to install the given requirements::
278
279 >>> get_install_info("FooBar (<=1.2)")
280 {'install': [<FooBar 1.1>], 'remove': [], 'conflict': []}
281
282 Conflict contains all the conflicting distributions, if there is a
283 conflict.
284 """
285 # this function does several things:
286 # 1. get a release specified by the requirements
287 # 2. gather its metadata, using setuptools compatibility if needed
288 # 3. compare this tree with what is currently installed on the system,
289 # return the requirements of what is missing
290 # 4. do that recursively and merge back the results
291 # 5. return a dict containing information about what is needed to install
292 # or remove
293
294 if not installed:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200295 logger.debug('Reading installed distributions')
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200296 installed = list(get_distributions(use_egg_info=True))
297
298 infos = {'install': [], 'remove': [], 'conflict': []}
299 # Is a compatible version of the project already installed ?
300 predicate = get_version_predicate(requirements)
301 found = False
302
303 # check that the project isn't already installed
304 for installed_project in installed:
305 # is it a compatible project ?
306 if predicate.name.lower() != installed_project.name.lower():
307 continue
308 found = True
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200309 logger.info('Found %s %s', installed_project.name,
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200310 installed_project.metadata['version'])
311
312 # if we already have something installed, check it matches the
313 # requirements
314 if predicate.match(installed_project.metadata['version']):
315 return infos
316 break
317
318 if not found:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200319 logger.debug('Project not installed')
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200320
321 if not index:
322 index = wrapper.ClientWrapper()
323
324 if not installed:
325 installed = get_distributions(use_egg_info=True)
326
327 # Get all the releases that match the requirements
328 try:
329 release = index.get_release(requirements)
330 except (ReleaseNotFound, ProjectNotFound):
331 raise InstallationException('Release not found: "%s"' % requirements)
332
333 if release is None:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200334 logger.info('Could not find a matching project')
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200335 return infos
336
337 metadata = release.fetch_metadata()
338
339 # we need to build setuptools deps if any
340 if 'requires_dist' not in metadata:
341 metadata['requires_dist'] = _get_setuptools_deps(release)
342
343 # build the dependency graph with local and required dependencies
344 dists = list(installed)
345 dists.append(release)
346 depgraph = generate_graph(dists)
347
348 # Get what the missing deps are
349 dists = depgraph.missing[release]
350 if dists:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200351 logger.info("Missing dependencies found, retrieving metadata")
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200352 # we have missing deps
353 for dist in dists:
354 _update_infos(infos, get_infos(dist, index, installed))
355
356 # Fill in the infos
357 existing = [d for d in installed if d.name == release.name]
358 if existing:
359 infos['remove'].append(existing[0])
360 infos['conflict'].extend(depgraph.reverse_list[existing[0]])
361 infos['install'].append(release)
362 return infos
363
364
365def _update_infos(infos, new_infos):
366 """extends the lists contained in the `info` dict with those contained
367 in the `new_info` one
368 """
369 for key, value in infos.items():
370 if key in new_infos:
371 infos[key].extend(new_infos[key])
372
373
374def _remove_dist(dist, paths=sys.path):
375 remove(dist.name, paths)
376
377
378def remove(project_name, paths=sys.path, auto_confirm=True):
Tarek Ziadef47fa582011-05-30 23:26:51 +0200379 """Removes a single project from the installation.
380
381 Returns True on success
382 """
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200383 dist = get_distribution(project_name, use_egg_info=True, paths=paths)
384 if dist is None:
385 raise PackagingError('Distribution "%s" not found' % project_name)
386 files = dist.list_installed_files(local=True)
387 rmdirs = []
388 rmfiles = []
389 tmp = tempfile.mkdtemp(prefix=project_name + '-uninstall')
Tarek Ziadef47fa582011-05-30 23:26:51 +0200390
391 def _move_file(source, target):
392 try:
393 os.rename(source, target)
394 except OSError as err:
395 return err
396 return None
397
398 success = True
399 error = None
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200400 try:
401 for file_, md5, size in files:
402 if os.path.isfile(file_):
403 dirname, filename = os.path.split(file_)
404 tmpfile = os.path.join(tmp, filename)
405 try:
Tarek Ziadef47fa582011-05-30 23:26:51 +0200406 error = _move_file(file_, tmpfile)
407 if error is not None:
408 success = False
409 break
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200410 finally:
411 if not os.path.isfile(file_):
412 os.rename(tmpfile, file_)
413 if file_ not in rmfiles:
414 rmfiles.append(file_)
415 if dirname not in rmdirs:
416 rmdirs.append(dirname)
417 finally:
418 shutil.rmtree(tmp)
419
Tarek Ziadef47fa582011-05-30 23:26:51 +0200420 if not success:
421 logger.info('%r cannot be removed.', project_name)
422 logger.info('Error: %s' % str(error))
423 return False
424
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200425 logger.info('Removing %r: ', project_name)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200426
427 for file_ in rmfiles:
428 logger.info(' %s', file_)
429
430 # Taken from the pip project
431 if auto_confirm:
432 response = 'y'
433 else:
434 response = ask('Proceed (y/n)? ', ('y', 'n'))
435
436 if response == 'y':
437 file_count = 0
438 for file_ in rmfiles:
439 os.remove(file_)
440 file_count += 1
441
442 dir_count = 0
443 for dirname in rmdirs:
444 if not os.path.exists(dirname):
445 # could
446 continue
447
448 files_count = 0
449 for root, dir, files in os.walk(dirname):
450 files_count += len(files)
451
452 if files_count > 0:
453 # XXX Warning
454 continue
455
456 # empty dirs with only empty dirs
457 if os.stat(dirname).st_mode & stat.S_IWUSR:
458 # XXX Add a callable in shutil.rmtree to count
459 # the number of deleted elements
460 shutil.rmtree(dirname)
461 dir_count += 1
462
463 # removing the top path
464 # XXX count it ?
465 if os.path.exists(dist.path):
466 shutil.rmtree(dist.path)
467
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200468 logger.info('Success: removed %d files and %d dirs',
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200469 file_count, dir_count)
470
Tarek Ziadef47fa582011-05-30 23:26:51 +0200471 return True
472
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200473
474def install(project):
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200475 logger.info('Getting information about %r...', project)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200476 try:
477 info = get_infos(project)
478 except InstallationException:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200479 logger.info('Cound not find %r', project)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200480 return
481
482 if info['install'] == []:
Tarek Ziadeb1b6e132011-05-30 12:07:49 +0200483 logger.info('Nothing to install')
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200484 return
485
486 install_path = get_config_var('base')
487 try:
488 install_from_infos(install_path,
489 info['install'], info['remove'], info['conflict'])
490
491 except InstallationConflict as e:
492 if logger.isEnabledFor(logging.INFO):
493 projects = ['%s %s' % (p.name, p.version) for p in e.args[0]]
494 logger.info('%r conflicts with %s', project, ','.join(projects))
495
496
497def _main(**attrs):
498 if 'script_args' not in attrs:
499 import sys
500 attrs['requirements'] = sys.argv[1]
501 get_infos(**attrs)
502
503if __name__ == '__main__':
504 _main()