Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(51)

Side by Side Diff: package_management.py

Issue 8965033: Adds Python package management to depot_tools. (Closed) Base URL: svn://svn.chromium.org/chrome/trunk/tools/depot_tools
Patch Set: Addressed review comments. Created 8 years, 11 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch | Annotate | Revision Log
« .gitignore ('K') | « .gitignore ('k') | no next file » | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
(Empty)
1 #!/usr/bin/env python
2 # Copyright (c) 2012 The Chromium Authors. All rights reserved.
3 # Use of this source code is governed by a BSD-style license that can be
4 # found in the LICENSE file.
5
6 """Package Management
7
8 This module is used to bring in external Python dependencies via eggs from the
9 PyPi repository. It is intended to work with any version of Python from 2.4
M-A Ruel 2012/01/09 02:34:15 Don't claim to support 2.4 unless you plan to supp
chrisha 2012/01/31 16:35:40 I tested it on 2.4, assuming that depot_tools stil
10 through 2.6 and beyond, as well as on any OS and hardware.
11
12 The approach is to create a site directory in depot_tools root which will
13 contain those packages that are listed as dependencies in the module variable
14 PACKAGES. Since we can't guarantee that setuptools is available in all
15 distributions this module also contains the ability to bootstrap the site
16 directory by manually downloading and installing setuptools. Once setuptools is
17 available it uses that to install the other packages in the traditional
18 manner.
19
20 Use is simple:
21
22 import package_management
23
24 # Before any imports from the site directory, call this. This only needs
25 # to be called in one place near the beginning of the program.
26 package_management.SetupSiteDirectory()
27
28 # If 'SetupSiteDirectory' fails it will complain with an error message but
29 # continue happily. Expect ImportErrors when trying to import any third
30 # party modules from the site directory.
31
32 import some_third_party_module
33
34 ... etc ...
35 """
36
37 import cStringIO
38 import os
39 import re
40 import shutil
41 import site
42 import subprocess
43 import sys
44 import tempfile
45 import urllib
46
47
48 # This is the version of setuptools that we will download if the local
49 # python distribution does not include one.
50 SETUPTOOLS = ('setuptools', '0.6c11')
51
52 # These are the packages that are to be installed in the site directory.
53 # easy_install makes it so that the most recently installed version of a
54 # package is the one that takes precedence, even if a newer version exists
55 # in the site directory. This allows us to blindly install these one on top
56 # of the other without worrying about whats already installed.
57 #
58 # NOTE: If we are often rolling these dependencies then users' site
59 # directories will grow monotonically. We could be purging any orphaned
60 # packages using the tools provided by pkg_resources.
61 PACKAGES = (('logilab-common', '0.57.1'),
62 ('logilab-astng', '0.23.1'),
63 ('pylint', '0.25.1'))
64
65
66 # The Python version suffix used in generating the site directory and in
67 # requesting packages from PyPi.
68 VERSION_SUFFIX = "%d.%d" % sys.version_info[0:2]
69
70 # This is the root directory of the depot_tools installation.
71 ROOT_DIR = os.path.abspath(os.path.dirname(__file__))
72
73 # This is the path of the site directory we will use. We make this
74 # python version specific so that this will continue to work even if the
75 # python version is rolled.
76 SITE_DIR = os.path.join(ROOT_DIR, 'site-packages-py%s' % VERSION_SUFFIX)
77
78 # This status file is created the last time PACKAGES were rolled successfully.
79 # It is used to determine if packages need to be rolled by comparing against
80 # the age of __file__.
81 LAST_ROLLED = os.path.join(SITE_DIR, 'last_rolled.txt')
82
83
84 class Error(Exception):
85 """The base class for all module errors."""
86 pass
87
88
89 class InstallError(Error):
90 """Thrown if an installation is unable to complete."""
91 pass
92
93
94 class Package(object):
95 """A package represents a release of a project.
96
97 We use this as a lightweight version of pkg_resources, allowing us to
98 perform an end-run around setuptools for the purpose of bootstrapping. Its
99 functionality is very limited.
100
101 Attributes:
102 name: the name of the package.
103 version: the version of the package.
104 safe_name: the safe name of the package.
105 safe_version: the safe version string of the package.
106 file_name: the filename-safe name of the package.
107 file_version: the filename-safe version string of the package.
108 """
109
110 def __init__(self, name, version):
111 """Initialize this package.
112
113 Args:
114 name: the name of the package.
115 version: the version of the package.
116 """
117 self.name = name
118 self.version = version
119 self.safe_name = Package._MakeSafeName(self.name)
120 self.safe_version = Package._MakeSafeVersion(self.version)
121 self.file_name = Package._MakeSafeForFilename(self.safe_name)
122 self.file_version = Package._MakeSafeForFilename(self.safe_version)
123
124 @staticmethod
125 def _MakeSafeName(name):
126 """Makes a safe package name, as per pkg_resources."""
127 return re.sub('[^A-Za-z0-9]+', '-', name)
128
129 @staticmethod
130 def _MakeSafeVersion(version):
131 """Makes a safe package version string, as per pkg_resources."""
132 version = re.sub('\s+', '.', version)
133 return re.sub('[^A-Za-z0-9\.]+', '-', version)
134
135 @staticmethod
136 def _MakeSafeForFilename(safe_name_or_version):
137 """Makes a safe name or safe version safe to use in a file name.
138 |safe_name_or_version| must be a safe name or version string as returned
139 by GetSafeName or GetSafeVersion.
140 """
141 return re.sub('-', '_', safe_name_or_version)
142
143 def GetAsRequirementString(self):
144 """Builds an easy_install requirements string representing this package."""
145 return '%s==%s' % (self.name, self.version)
146
147 def GetFilename(self, extension=None):
148 """Builds a filename for this package using the setuptools convention.
149 If |extensions| is provided it will be appended to the generated filename,
150 otherwise only the basename is returned.
151
152 The following url discusses the filename format:
153
154 http://svn.python.org/projects/sandbox/trunk/setuptools/doc/formats.txt
155 """
156 filename = '%s-%s-py%s' % (self.file_name, self.file_version,
157 VERSION_SUFFIX)
158
159 if extension:
160 if extension[0] != '.':
M-A Ruel 2012/01/09 02:34:15 if not extension.startswith('.'):
161 filename += '.'
162 filename += extension
163
164 return filename
165
166 def GetPyPiUrl(self, extension):
167 """Returns the URL where this package is hosted on PyPi."""
168 return 'http://pypi.python.org/packages/2.6/%c/%s/%s' % (
169 self.file_name[0], self.file_name, self.GetFilename(extension))
170
171 def DownloadEgg(self, dest_dir, overwrite=False):
172 """Downloads the EGG for this URL.
173
174 Args:
175 dest_dir: The directory where the EGG should be written. If the EGG
176 has already been downloaded and cached the returned path may not
177 be in this directory.
178 overwite: If True the destination path will be overwritten even if
179 it already exists. Defaults to False.
180
181 Returns:
182 The path to the written EGG.
183
184 Raises:
185 Error: if dest_dir doesn't exist, the EGG is unable to be written,
186 the URL doesn't exist, or the server returned an error, or the
187 transmission was interrupted.
188 """
189 if not os.path.exists(dest_dir):
190 raise Error('Path does not exist: %s' % dest_dir)
191
192 if not os.path.isdir(dest_dir):
193 raise Error('Path is not a directory: %s' % dest_dir)
194
195 filename = os.path.abspath(os.path.join(dest_dir, self.GetFilename('egg')))
196 if os.path.exists(filename):
197 if os.path.isdir(filename):
198 raise Error('Path is a directory: %s' % filename)
199 if not overwrite:
200 return filename
201
202 url = self.GetPyPiUrl('egg')
203
204 try:
205 (path, headers) = urllib.urlretrieve(url, filename)
206 return path
207 except (IOError, urllib.ContentTooShortError):
208 # Reraise with a new error type, keeping the original message and
209 # traceback.
210 raise Error, sys.exc_info()[1], sys.exc_info()[2]
211
212
213 def AddToPythonPath(path):
214 """Adds the provided path to the head of PYTHONPATH and sys.path."""
M-A Ruel 2012/01/09 02:34:15 path = os.path.abspath(path)
215 if path not in sys.path:
216 sys.path.insert(0, path)
217
218 paths = os.environ.get('PYTHONPATH', '').split(os.pathsep)
219 if path not in paths:
220 paths.insert(0, path)
221 os.environ['PYTHONPATH'] = os.pathsep.join(paths)
222
223
224 def AddSiteDirectory(path):
225 """Adds the provided path to the runtime as a site directory.
226
227 Any modules that are in the site directory will be available for importing
228 after this returns. If modules are added or deleted this must be called
229 again for the changes to be reflected in the runtime.
230
231 This calls both AddToPythonPath and site.addsitedir. Both are needed to
232 convince easy_install to treat |path| as a site directory.
233 """
234 AddToPythonPath(path)
235 site.addsitedir(path)
236
237
238 def EnsureSiteDirectory(path):
239 """Creates and/or adds the provided path to the runtime as a site directory.
240
241 This works like AddSiteDirectory but it will create the directory if it
242 does not yet exist.
243
244 Raise:
245 Error: if the site directory is unable to be created, or if it exists and
246 is not a directory.
247 """
248 if os.path.exists(path):
249 if not os.path.isdir(path):
250 raise Error('Path is not a directory: %s' % path)
251 else:
252 try:
253 os.mkdir(path)
254 except IOError:
255 raise Error('Unable to create directory: %s' % path)
256
257 AddSiteDirectory(path)
258
259
260 def ModuleIsFromPackage(module, package_path):
261 """Determines if a module has been imported from a given package.
262
263 Args:
264 module: the module to test.
265 package_path: the path to the package to test.
266
267 Returns:
268 True if |module| has been imported from |package_path|, False otherwise.
269 """
270 try:
271 m = os.path.abspath(module.__file__)
272 p = os.path.abspath(package_path)
273 if len(m) <= len(p):
274 return False
275 if m[0:len(p)] != p:
276 return False
277 return m[len(p)] == os.sep
278 except AttributeError:
279 return False
280
281
282 def _CaptureStdStreams(function, *args, **kwargs):
283 """Captures stdout and stderr while running the provided function.
284
285 This only works if |function| only accesses sys.stdout and sys.stderr. If
286 we need more than this we'll have to use subprocess.Popen.
287
288 Args:
289 function: the function to be called.
290 args: the arguments to pass to |function|.
291 kwargs: the keyword arguments to pass to |function|.
292 """
293 orig_stdout = sys.stdout
294 orig_stderr = sys.stderr
295 sys.stdout = cStringIO.StringIO()
296 sys.stderr = cStringIO.StringIO()
297 try:
298 return function(*args, **kwargs)
299 finally:
300 sys.stdout = orig_stdout
301 sys.stderr = orig_stderr
302
303
304 def InstallPackage(url_or_req, site_dir):
305 """Installs a package to a site directory.
306
307 |site_dir| must exist and already be an active site directory. setuptools
308 must in the path. Uses easy_install which may involve a download from
309 pypi.python.org, so this also requires network access.
310
311 Args:
312 url_or_req: the package to install, expressed as an URL (may be local),
313 or a requirement string.
314 site_dir: the site directory in which to install it.
315
316 Raises:
317 InstallError: if installation fails for any reason.
318 """
319 args = ['--quiet', '--install-dir', site_dir, '--exclude-scripts',
320 '--always-unzip', '--no-deps', url_or_req]
321
322 # The easy_install script only calls SystemExit if something goes wrong.
323 # Otherwise, it falls through returning None.
324 try:
325 import setuptools.command.easy_install
326 _CaptureStdStreams(setuptools.command.easy_install.main, args)
327 except (ImportError, SystemExit), e:
328 # Re-raise the error, preserving the stack trace and message.
329 raise InstallError, sys.exc_info()[1], sys.exc_info()[2]
330
331
332 def _RunInSubprocess(pycode):
333 """Launches a python subprocess with the provided code.
334
335 The subprocess will be launched with the same stdout and stderr. The
336 subprocess will use the same instance of python as is currently running,
337 passing |pycode| as arguments to this script. |pycode| will be interpreted
338 as python code in the context of this module.
339
340 Returns:
341 True if the subprocess returned 0, False if it returned an error.
342 """
343 return not subprocess.call([sys.executable, __file__, pycode])
344
345
346 def _LoadSetupToolsFromEggAndInstall(egg_path):
347 """Loads setuptools from the provided egg |egg_path|, and installs it to
348 SITE_DIR.
349
350 This is intended to be run from a subprocess as it pollutes the running
351 instance of Python by importing a module and then forcibly deleting its
352 source.
353
354 Returns:
355 True on success, False on failure.
356 """
357 AddToPythonPath(egg_path)
358
359 try:
360 # Import setuptools and ensure it comes from the EGG.
361 import setuptools
362 if not ModuleIsFromPackage(setuptools, egg_path):
363 raise ImportError()
364 except ImportError:
365 print ' Unable to import downloaded package!'
366 return False
367
368 try:
369 print ' Using setuptools to install itself ...'
370 InstallPackage(egg_path, SITE_DIR)
371 except InstallError:
372 print ' Unable to install setuptools!'
373 return False
374
375 return True
376
377
378 def BootstrapSetupTools():
379 """Bootstraps the runtime with setuptools.
380
381 Will try to import setuptools directly. If not found it will attempt to
382 download it and load it from there. If the download is successful it will
383 then use setuptools to install itself in the site directory.
384
385 This is meant to be run from a child process as it modifies the running
386 instance of Python by importing modules and then physically deleting them
387 from disk.
388
389 Returns:
390 Returns True if 'import setuptools' will succeed, False otherwise.
391 """
392 AddSiteDirectory(SITE_DIR)
393
394 # Check if setuptools is already available. If so, we're done.
395 try:
396 import setuptools
397 return True
398 except ImportError:
399 pass
400
401 print 'Bootstrapping setuptools ...'
402
403 EnsureSiteDirectory(SITE_DIR)
404
405 # Download the egg to a temp directory.
406 dest_dir = tempfile.mkdtemp('depot_tools')
407 path = None
408 try:
409 package = Package(*SETUPTOOLS)
410 print ' Downloading %s ...' % package.GetFilename()
411 path = package.DownloadEgg(dest_dir)
412 except Error:
413 print ' Download failed!'
414 shutil.rmtree(dest_dir)
415 return False
416 except:
M-A Ruel 2012/01/09 02:34:15 Not a fan. (IOError, OSError) would probably be fi
chrisha 2012/01/31 16:35:40 Not strictly needed. Removed.
417 shutil.rmtree(dest_dir)
418
419 try:
420 # Load the downloaded egg, and install it to the site directory. Do this
421 # in a subprocess so as not to pollute this runtime.
422 pycode = '_LoadSetupToolsFromEggAndInstall(%s)' % repr(path)
423 if not _RunInSubprocess(pycode):
424 raise Error
M-A Ruel 2012/01/09 02:34:15 raise Error()
425
426 # Reload our site directory, which should now contain setuptools.
427 AddSiteDirectory(SITE_DIR)
428
429 # Try and import setupttols
430 import setuptools
431 except ImportError:
432 print ' Unable to import setuptools!'
433 return False
434 except Error:
435 # This happens if RunInSubProcess fails, and the appropriate error has
436 # already been written.
437 return False
438 finally:
439 # Delete the temp directory.
440 shutil.rmtree(dest_dir)
441
442 return True
443
444
445 def _GetModTime(path):
446 """Gets the last modification time associated with |path| in seconds since
447 epoch, returning 0 if |path| does not exist.
448 """
449 try:
450 return os.stat(path).st_mtime
451 except:
452 # This error is different depending on the OS.
453 return 0
454
455
456 def _SiteDirectoryIsUpToDate():
457 return _GetModTime(LAST_ROLLED) > _GetModTime(__file__)
458
459
460 def UpdateSiteDirectory():
461 """Installs the packages from PACKAGES if they are not already installed.
462 At this point we must have setuptools in the site directory.
463
464 This is intended to be run in a subprocess *prior* to the site directory
465 having been added to the parent process as it may cause packages to be
466 added and/or removed.
467
468 Returns:
469 True on success, False otherwise.
470 """
471 if _SiteDirectoryIsUpToDate():
472 return True
473
474 try:
475 AddSiteDirectory(SITE_DIR)
476 import pkg_resources
477
478 # Determine if any packages actually need installing.
479 missing_packages = []
480 for package in [SETUPTOOLS] + list(PACKAGES):
481 pkg = Package(*package)
482 req = pkg.GetAsRequirementString()
483
484 # It may be that this package is already available in the site
485 # directory. If so, we can skip past it without trying to install it.
486 pkg_req = pkg_resources.Requirement.parse(req)
487 try:
488 dist = pkg_resources.working_set.find(pkg_req)
489 if dist:
490 continue
491 except pkg_resources.VersionConflict:
492 # This happens if another version of the package is already
493 # installed in another site directory (ie: the system site directory).
494 pass
495
496 missing_packages.append(pkg)
497
498 # Install the missing packages.
499 if missing_packages:
500 print 'Updating python packages ...'
501 for pkg in missing_packages:
502 print ' Installing %s ...' % pkg.GetFilename()
503 InstallPackage(pkg.GetAsRequirementString(), SITE_DIR)
504
505 # Touch the status file so we know that we're up to date next time.
506 open(LAST_ROLLED, 'wb')
507 except InstallError, e:
508 print ' Installation failed: %s' % str(e)
509 return False
510
511 return True
512
513
514 def SetupSiteDirectory():
515 """Sets up the site directory, bootstrapping setuptools if necessary.
516
517 If this finishes successfully then SITE_DIR will exist and will contain
518 the appropriate version of setuptools and all of the packages listed in
519 PACKAGES.
520
521 This is the main workhorse of this module. Calling this will do everything
522 necessary to ensure that you have the desired packages installed in the
523 site directory, and the site directory enabled in this process.
524
525 Returns:
526 True on success, False on failure.
527 """
528 if _SiteDirectoryIsUpToDate():
529 AddSiteDirectory(SITE_DIR)
530 return True
531
532 if not _RunInSubprocess('BootstrapSetupTools()'):
533 return False
534
535 if not _RunInSubprocess('UpdateSiteDirectory()'):
536 return False
537
538 # Process the site directory so that the packages within it are available
539 # for import.
540 AddSiteDirectory(SITE_DIR)
541
542 return True
543
544
545 def CanImportFromSiteDirectory(package_name):
546 """Determines if the given package can be imported from the site directory.
547
548 Args:
549 package_name: the name of the package to import.
550
551 Returns:
552 True if 'import package_name' will succeed and return a module from the
553 site directory, False otherwise.
554 """
555 try:
556 return ModuleIsFromPackage(__import__(package_name), SITE_DIR)
557 except ImportError:
558 return False
559
560
561 def Test():
562 """Runs SetupSiteDirectory and then tries to load pylint, ensuring that it
563 comes from the site directory just created. This is an end-to-end unittest
564 and allows for simple testing from the command-line by running
565
566 ./package_management.py 'Test()'
567 """
568 print 'Testing package_management.'
569 if not SetupSiteDirectory():
570 print 'SetupSiteDirectory failed.'
571 return False
572 if not CanImportFromSiteDirectory('pylint'):
573 print 'CanImportFromSiteDirectory failed.'
574 return False
575 print 'Success!'
576 return True
577
578
579 def Main():
580 """The main entry for the package management script.
581
582 If no arguments are provided simply runs SetupSiteDirectory. If arguments
583 have been passed we execute the first argument as python code in the
584 context of this module. This mechanism is used during the bootstrap
585 process so that the main instance of Python does not have its runtime
586 polluted by various intermediate packages and imports.
587
588 Returns:
589 0 on success, 1 otherwise.
590 """
591 if len(sys.argv) == 2:
592 result = False
593 exec('result = %s' % sys.argv[1])
594
595 # Translate the success state to a return code.
596 return not result
597 else:
598 return not SetupSiteDirectory()
599
600
601 if __name__ == '__main__':
602 sys.exit(Main())
OLDNEW
« .gitignore ('K') | « .gitignore ('k') | no next file » | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698