blob: 3904727d450309901019b0b69c6b3def0d224c45 [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:
50 # not using os.path.join() because basename() might not be
51 # unique in destination
52 new = "%s%s" % (destination, old)
53
54 # try to make the paths.
55 try:
56 os.makedirs(os.path.dirname(new))
57 except OSError as e:
58 if e.errno == errno.EEXIST:
59 pass
60 else:
61 raise e
62 os.rename(old, new)
63 yield old, new
64
65
66def _run_distutils_install(path):
67 # backward compat: using setuptools or plain-distutils
68 cmd = '%s setup.py install --record=%s'
69 record_file = os.path.join(path, 'RECORD')
70 os.system(cmd % (sys.executable, record_file))
71 if not os.path.exists(record_file):
72 raise ValueError('failed to install')
73 else:
74 egginfo_to_distinfo(record_file, remove_egginfo=True)
75
76
77def _run_setuptools_install(path):
78 cmd = '%s setup.py install --record=%s --single-version-externally-managed'
79 record_file = os.path.join(path, 'RECORD')
80 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')
93 except (IOError, os.error, PackagingError, CCompilerError) as msg:
94 raise SystemExit("error: " + str(msg))
95
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
120 """
121 path = os.path.abspath(path)
122 if os.path.isdir(path):
123 logger.info('installing from source directory: %s', path)
124 _run_install_from_dir(path)
125 elif _is_archive_file(path):
126 logger.info('installing from archive: %s', path)
127 _unpacked_dir = tempfile.mkdtemp()
128 shutil.unpack_archive(path, _unpacked_dir)
129 _run_install_from_archive(_unpacked_dir)
130 else:
131 logger.warning('no projects to install')
132
133
134def _run_install_from_archive(source_dir):
135 # XXX need a better way
136 for item in os.listdir(source_dir):
137 fullpath = os.path.join(source_dir, item)
138 if os.path.isdir(fullpath):
139 source_dir = fullpath
140 break
141 return _run_install_from_dir(source_dir)
142
143
144install_methods = {
145 'packaging': _run_packaging_install,
146 'setuptools': _run_setuptools_install,
147 'distutils': _run_distutils_install}
148
149
150def _run_install_from_dir(source_dir):
151 old_dir = os.getcwd()
152 os.chdir(source_dir)
153 install_method = get_install_method(source_dir)
154 func = install_methods[install_method]
155 try:
156 func = install_methods[install_method]
157 return func(source_dir)
158 finally:
159 os.chdir(old_dir)
160
161
162def install_dists(dists, path, paths=sys.path):
163 """Install all distributions provided in dists, with the given prefix.
164
165 If an error occurs while installing one of the distributions, uninstall all
166 the installed distribution (in the context if this function).
167
168 Return a list of installed dists.
169
170 :param dists: distributions to install
171 :param path: base path to install distribution in
172 :param paths: list of paths (defaults to sys.path) to look for info
173 """
174 if not path:
175 path = tempfile.mkdtemp()
176
177 installed_dists = []
178 for dist in dists:
179 logger.info('installing %s %s', dist.name, dist.version)
180 try:
181 _install_dist(dist, path)
182 installed_dists.append(dist)
183 except Exception as e:
184 logger.info('failed: %s', e)
185
186 # reverting
187 for installed_dist in installed_dists:
188 logger.info('reverting %s', installed_dist)
189 _remove_dist(installed_dist, paths)
190 raise e
191 return installed_dists
192
193
194def install_from_infos(install_path=None, install=[], remove=[], conflicts=[],
195 paths=sys.path):
196 """Install and remove the given distributions.
197
198 The function signature is made to be compatible with the one of get_infos.
199 The aim of this script is to povide a way to install/remove what's asked,
200 and to rollback if needed.
201
202 So, it's not possible to be in an inconsistant state, it could be either
203 installed, either uninstalled, not half-installed.
204
205 The process follow those steps:
206
207 1. Move all distributions that will be removed in a temporary location
208 2. Install all the distributions that will be installed in a temp. loc.
209 3. If the installation fails, rollback (eg. move back) those
210 distributions, or remove what have been installed.
211 4. Else, move the distributions to the right locations, and remove for
212 real the distributions thats need to be removed.
213
214 :param install_path: the installation path where we want to install the
215 distributions.
216 :param install: list of distributions that will be installed; install_path
217 must be provided if this list is not empty.
218 :param remove: list of distributions that will be removed.
219 :param conflicts: list of conflicting distributions, eg. that will be in
220 conflict once the install and remove distribution will be
221 processed.
222 :param paths: list of paths (defaults to sys.path) to look for info
223 """
224 # first of all, if we have conflicts, stop here.
225 if conflicts:
226 raise InstallationConflict(conflicts)
227
228 if install and not install_path:
229 raise ValueError("Distributions are to be installed but `install_path`"
230 " is not provided.")
231
232 # before removing the files, we will start by moving them away
233 # then, if any error occurs, we could replace them in the good place.
234 temp_files = {} # contains lists of {dist: (old, new)} paths
235 temp_dir = None
236 if remove:
237 temp_dir = tempfile.mkdtemp()
238 for dist in remove:
239 files = dist.list_installed_files()
240 temp_files[dist] = _move_files(files, temp_dir)
241 try:
242 if install:
243 install_dists(install, install_path, paths)
244 except:
245 # if an error occurs, put back the files in the right place.
246 for files in temp_files.values():
247 for old, new in files:
248 shutil.move(new, old)
249 if temp_dir:
250 shutil.rmtree(temp_dir)
251 # now re-raising
252 raise
253
254 # we can remove them for good
255 for files in temp_files.values():
256 for old, new in files:
257 os.remove(new)
258 if temp_dir:
259 shutil.rmtree(temp_dir)
260
261
262def _get_setuptools_deps(release):
263 # NotImplementedError
264 pass
265
266
267def get_infos(requirements, index=None, installed=None, prefer_final=True):
268 """Return the informations on what's going to be installed and upgraded.
269
270 :param requirements: is a *string* containing the requirements for this
271 project (for instance "FooBar 1.1" or "BarBaz (<1.2)")
272 :param index: If an index is specified, use this one, otherwise, use
273 :class index.ClientWrapper: to get project metadatas.
274 :param installed: a list of already installed distributions.
275 :param prefer_final: when picking up the releases, prefer a "final" one
276 over a beta/alpha/etc one.
277
278 The results are returned in a dict, containing all the operations
279 needed to install the given requirements::
280
281 >>> get_install_info("FooBar (<=1.2)")
282 {'install': [<FooBar 1.1>], 'remove': [], 'conflict': []}
283
284 Conflict contains all the conflicting distributions, if there is a
285 conflict.
286 """
287 # this function does several things:
288 # 1. get a release specified by the requirements
289 # 2. gather its metadata, using setuptools compatibility if needed
290 # 3. compare this tree with what is currently installed on the system,
291 # return the requirements of what is missing
292 # 4. do that recursively and merge back the results
293 # 5. return a dict containing information about what is needed to install
294 # or remove
295
296 if not installed:
297 logger.info('reading installed distributions')
298 installed = list(get_distributions(use_egg_info=True))
299
300 infos = {'install': [], 'remove': [], 'conflict': []}
301 # Is a compatible version of the project already installed ?
302 predicate = get_version_predicate(requirements)
303 found = False
304
305 # check that the project isn't already installed
306 for installed_project in installed:
307 # is it a compatible project ?
308 if predicate.name.lower() != installed_project.name.lower():
309 continue
310 found = True
311 logger.info('found %s %s', installed_project.name,
312 installed_project.metadata['version'])
313
314 # if we already have something installed, check it matches the
315 # requirements
316 if predicate.match(installed_project.metadata['version']):
317 return infos
318 break
319
320 if not found:
321 logger.info('project not installed')
322
323 if not index:
324 index = wrapper.ClientWrapper()
325
326 if not installed:
327 installed = get_distributions(use_egg_info=True)
328
329 # Get all the releases that match the requirements
330 try:
331 release = index.get_release(requirements)
332 except (ReleaseNotFound, ProjectNotFound):
333 raise InstallationException('Release not found: "%s"' % requirements)
334
335 if release is None:
336 logger.info('could not find a matching project')
337 return infos
338
339 metadata = release.fetch_metadata()
340
341 # we need to build setuptools deps if any
342 if 'requires_dist' not in metadata:
343 metadata['requires_dist'] = _get_setuptools_deps(release)
344
345 # build the dependency graph with local and required dependencies
346 dists = list(installed)
347 dists.append(release)
348 depgraph = generate_graph(dists)
349
350 # Get what the missing deps are
351 dists = depgraph.missing[release]
352 if dists:
353 logger.info("missing dependencies found, retrieving metadata")
354 # we have missing deps
355 for dist in dists:
356 _update_infos(infos, get_infos(dist, index, installed))
357
358 # Fill in the infos
359 existing = [d for d in installed if d.name == release.name]
360 if existing:
361 infos['remove'].append(existing[0])
362 infos['conflict'].extend(depgraph.reverse_list[existing[0]])
363 infos['install'].append(release)
364 return infos
365
366
367def _update_infos(infos, new_infos):
368 """extends the lists contained in the `info` dict with those contained
369 in the `new_info` one
370 """
371 for key, value in infos.items():
372 if key in new_infos:
373 infos[key].extend(new_infos[key])
374
375
376def _remove_dist(dist, paths=sys.path):
377 remove(dist.name, paths)
378
379
380def remove(project_name, paths=sys.path, auto_confirm=True):
381 """Removes a single project from the installation"""
382 dist = get_distribution(project_name, use_egg_info=True, paths=paths)
383 if dist is None:
384 raise PackagingError('Distribution "%s" not found' % project_name)
385 files = dist.list_installed_files(local=True)
386 rmdirs = []
387 rmfiles = []
388 tmp = tempfile.mkdtemp(prefix=project_name + '-uninstall')
389 try:
390 for file_, md5, size in files:
391 if os.path.isfile(file_):
392 dirname, filename = os.path.split(file_)
393 tmpfile = os.path.join(tmp, filename)
394 try:
395 os.rename(file_, tmpfile)
396 finally:
397 if not os.path.isfile(file_):
398 os.rename(tmpfile, file_)
399 if file_ not in rmfiles:
400 rmfiles.append(file_)
401 if dirname not in rmdirs:
402 rmdirs.append(dirname)
403 finally:
404 shutil.rmtree(tmp)
405
406 logger.info('removing %r: ', project_name)
407
408 for file_ in rmfiles:
409 logger.info(' %s', file_)
410
411 # Taken from the pip project
412 if auto_confirm:
413 response = 'y'
414 else:
415 response = ask('Proceed (y/n)? ', ('y', 'n'))
416
417 if response == 'y':
418 file_count = 0
419 for file_ in rmfiles:
420 os.remove(file_)
421 file_count += 1
422
423 dir_count = 0
424 for dirname in rmdirs:
425 if not os.path.exists(dirname):
426 # could
427 continue
428
429 files_count = 0
430 for root, dir, files in os.walk(dirname):
431 files_count += len(files)
432
433 if files_count > 0:
434 # XXX Warning
435 continue
436
437 # empty dirs with only empty dirs
438 if os.stat(dirname).st_mode & stat.S_IWUSR:
439 # XXX Add a callable in shutil.rmtree to count
440 # the number of deleted elements
441 shutil.rmtree(dirname)
442 dir_count += 1
443
444 # removing the top path
445 # XXX count it ?
446 if os.path.exists(dist.path):
447 shutil.rmtree(dist.path)
448
449 logger.info('success: removed %d files and %d dirs',
450 file_count, dir_count)
451
452
453def install(project):
454 logger.info('getting information about %r', project)
455 try:
456 info = get_infos(project)
457 except InstallationException:
458 logger.info('cound not find %r', project)
459 return
460
461 if info['install'] == []:
462 logger.info('nothing to install')
463 return
464
465 install_path = get_config_var('base')
466 try:
467 install_from_infos(install_path,
468 info['install'], info['remove'], info['conflict'])
469
470 except InstallationConflict as e:
471 if logger.isEnabledFor(logging.INFO):
472 projects = ['%s %s' % (p.name, p.version) for p in e.args[0]]
473 logger.info('%r conflicts with %s', project, ','.join(projects))
474
475
476def _main(**attrs):
477 if 'script_args' not in attrs:
478 import sys
479 attrs['requirements'] = sys.argv[1]
480 get_infos(**attrs)
481
482if __name__ == '__main__':
483 _main()