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

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: . Created 9 years 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) 2011 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
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 loading 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 # This is the root directory of the depot_tools installation.
66 ROOT_DIR = os.path.abspath(os.path.dirname(__file__))
67
68 # This is the path of the site directory we will use. We make this
69 # python version specific so that this will continue to work even if the
70 # python version is rolled.
71 SITE_DIR = os.path.join(ROOT_DIR, 'site-packages-py%s' % sys.version[0:3])
Sigurður Ásgeirsson 2011/12/17 15:26:03 format from sys.version_info, perhaps?
chrisha 2012/01/06 21:21:28 Done.
72
73 # This status file is created the last time PACKAGES were rolled successfully.
74 # It is used to determine if packages need to be rolled by comparing against
75 # the age of __file__.
76 LAST_ROLLED = os.path.join(SITE_DIR, 'last_rolled.txt')
77
78
79 class Error(Exception):
80 """The base class for all module errors."""
81 pass
82
83
84 class InstallError(Error):
85 """Thrown if an installation is unable to complete."""
86 pass
87
88
89 class Package(object):
90 """A package represents a release of a project.
91
92 We use this as a lightweight version of pkg_resources, allowing us to
93 perform an endrun around setuptools for the purpose of bootstrapping. Its
Sigurður Ásgeirsson 2011/12/17 15:26:03 nit: endrun->end-run?
chrisha 2012/01/06 21:21:28 Done.
94 functionality is very limited.
95
96 Attributes:
97 name: the name of the package.
98 version: the version of the package.
99 safe_name: the safe name of the package.
100 safe_version: the safe version string of the package.
101 file_name: the filename-safe name of the package.
102 file_version: the filename-safe version string of the package.
103 """
104
105 def __init__(self, name, version):
106 """Initialize this package.
107
108 Args:
109 name: the name of the package.
110 version: the version of the package.
111 """
112 self.name = name
113 self.version = version
114 self.safe_name = Package._MakeSafeName(self.name)
115 self.safe_version = Package._MakeSafeVersion(self.version)
116 self.file_name = Package._MakeSafeForFilename(self.safe_name)
117 self.file_version = Package._MakeSafeForFilename(self.safe_version)
118
119 @staticmethod
120 def _MakeSafeName(name):
121 """Makes a safe package name.
122
123 Returns:
124 The package name cleaned as per pkg_resources.
125 """
126 return re.sub('[^A-Za-z0-9]+', '-', name)
127
128 @staticmethod
129 def _MakeSafeVersion(version):
130 """Makes a safe package version string.
131
132 Returns:
133 The version string cleaned as per pkg_resources.
134 """
135 version = re.sub('\s+', '.', version)
136 return re.sub('[^A-Za-z0-9\.]+', '-', version)
137
138 @staticmethod
139 def _MakeSafeForFilename(safe_name_or_version):
140 """Makes a safe name or safe version safe to use in a file name.
M-A Ruel 2011/12/19 21:04:23 Stylistic note; I'm not a fan of functions with a
chrisha 2012/01/06 21:21:28 Noted ;)
141
142 Args:
143 safe_name_or_version: a safe name or string as returned by
144 GetSafeName() or GetSafeVersion().
145
146 Returns:
147 The safe name or version escaped as per pkg_resources.
148 """
149 return re.sub('-', '_', safe_name_or_version)
150
151 def GetAsRequirementString(self):
152 """Builds an easy_install requirements string representing this package.
153
154 Returns:
155 A requirement string that can be used with easy_install.
156 """
157 return '%s==%s' % (self.name, self.version)
158
159 def GetFilename(self, extension=None):
160 """Builds a filename for this package using the setuptools convention.
161
162 The following url discusses the filename format:
163
164 http://svn.python.org/projects/sandbox/trunk/setuptools/doc/formats.txt
165
166 Args:
167 extension: If None, returns a basename. Otherwise, uses the provided
168 extension.
169
170 Returns:
171 The filename for this package according to the setuptools convention.
172 """
173 filename = '%s-%s-py%s' % (self.file_name, self.file_version,
174 sys.version[0:3])
175
176 if extension:
177 if extension[0] != '.':
178 filename += '.'
179 filename += extension
180
181 return filename
182
183 def GetPyPiUrl(self, extension):
184 """Returns the URL where this package lives on PyPI.
185
186 Returns:
187 A string representing the HTTP URL where this package is hosted at
188 pypi.python.org.
189 """
190 return 'http://pypi.python.org/packages/2.6/%c/%s/%s' % (
191 self.file_name[0], self.file_name, self.GetFilename(extension))
192
193 def DownloadEgg(self, dest_dir, overwrite=False):
194 """Downloads the EGG for this URL.
195
196 Args:
197 dest_dir: The directory where the EGG should be written.
198 overwite: If True the destination path will be overwritten even if
199 it already exists. Defaults to False.
200
201 Returns:
202 The path to the written EGG.
203
204 Raises:
205 Error: if dest_dir doesn't exist, the EGG is unable to be written,
206 the URL doesn't exist, or the server returned an error, or the
207 transmission was interrupted.
208 """
209 if not os.path.exists(dest_dir):
210 raise Error('Path does not exist: %s' % dest_dir)
211
212 if not os.path.isdir(dest_dir):
213 raise Error('Path is not a directory: %s' % dest_dir)
214
215 filename = os.path.abspath(os.path.join(dest_dir, self.GetFilename('egg')))
216 if os.path.exists(filename):
217 if os.path.isdir(filename):
218 raise Error('Path is a directory: %s' % filename)
219 if not overwrite:
220 return filename
221
222 url = self.GetPyPiUrl('egg')
223
224 try:
225 (path, headers) = urllib.urlretrieve(url, filename)
226
227 # If the path where the object was downloaded to does not match the
M-A Ruel 2011/12/19 21:04:23 That may happen?
chrisha 2012/01/06 21:21:28 There's some mention of caching; if a particular f
228 # location to which we wanted to write it, copy it.
229 if path != filename:
230 shutil.copyfile(path, filename)
231
232 except IOError, e:
233 raise Error, sys.exc_info()[1], sys.exc_info()[2]
M-A Ruel 2011/12/19 21:04:23 Use the new format; raise Error(sys.exc_info()[1],
chrisha 2012/01/06 21:21:28 That's not quite equivalent. I want to recast the
234 except urllib.ContentTooShortError, e:
235 raise Error, sys.exc_info()[1], sys.exc_info()[2]
236
237 return filename
238
239
240 def AddToPythonPath(path):
241 """Adds the provided path to the head of PYTHONPATH and sys.path.
242
243 Args:
244 path: the path to add.
M-A Ruel 2011/12/19 21:04:23 A path is a path, don't document that. Silly styl
chrisha 2012/01/06 21:21:28 Done.
245 """
246 if path not in sys.path:
247 sys.path.insert(0, path)
248
249 paths = os.environ.get('PYTHONPATH', '').split(os.pathsep)
250 if path not in paths:
251 paths.insert(0, path)
252 os.environ['PYTHONPATH'] = os.pathsep.join(paths)
253
254
255 def RemoveFromPythonPath(path):
256 """Removes the provided path from PYTHONPATH and sys.path.
257
258 Args:
259 path: the path to remove.
260 """
261 def RemoveFromList(paths):
262 for i in xrange(len(paths), 0, -1):
263 if paths[i - 1] == path:
264 paths.pop(i - 1)
265
266 if path in sys.path:
267 RemoveFromList(sys.path)
268
269 paths = os.environ.get('PYTHONPATH', '').split(os.pathsep)
270 if path in paths:
271 RemoveFromList(paths)
272 os.environ['PYTHONPATH'] = os.pathsep.join(paths)
273
274
275 def AddSiteDirectory(path):
276 """Adds the provided path to the runtime as a site directory.
277
278 Any modules that are in the site directory will be available for importing
279 after this returns. If modules are added or deleted this must be called
280 again for the changes to be reflected in the runtime.
281
282 This calls both AddToPythonPath and site.addsitedir. Both are needed to
283 convince easy_install to treat |path| as a site directory.
284
285 Args:
286 path: the path of the site directory to add.
287 """
288 AddToPythonPath(path)
289 site.addsitedir(path)
290
291
292 def CreateOrAddSiteDirectory(path):
Sigurður Ásgeirsson 2011/12/17 15:26:03 Maybe name EnsureSiteDirectory?
chrisha 2012/01/06 21:21:28 Done.
293 """Creates and/or adds the provided path to the runtime as a site directory.
294
295 This works like AddSiteDirectory but it will create the directory if it
296 does not yet exist.
297
298 Args:
299 path: the path of the site directory to create and/or add.
300
301 Raise:
302 Error: if the site directory is unable to be created, or if it exist and
303 is not a directory.
304 """
305 if os.path.exists(path):
306 if not os.path.isdir(path):
307 raise Error('Path is not a directory: %s' % path)
308 else:
309 try:
310 os.mkdir(path)
311 except IOError:
312 raise Error('Unable to create directory: %s' % path)
313
314 AddSiteDirectory(path)
315
316
317 def ModuleIsFromPackage(module, package_path):
318 """Determines if a module has been imported from a given package.
319
320 Args:
321 module: the module to test.
322 package_path: the path to the package to test.
323
324 Returns:
325 True if |module| has been imported from |package_path|, False otherwise.
326 """
327 m = os.path.abspath(module.__file__)
328 p = os.path.abspath(package_path)
329 if len(m) <= len(p):
330 return False
331 if m[0:len(p)] != p:
332 return False
333 return m[len(p)] == os.sep
334
335
336 def _CaptureStdStreams(function, *args, **kwargs):
337 """Captures stdout and stderr while running the provided function.
338
339 This only works if |function| only accesses sys.stdout and sys.stderr. If
340 we need more than this we'll have to use subprocess.Popen.
341
342 Args:
343 function: the function to be called.
344 args: the arguments to pass to |function|.
345 kwargs: the keyword arguments to pass to |function|.
346 """
347 string_stdout = cStringIO.StringIO()
348 string_stderr = cStringIO.StringIO()
349 orig_stdout = sys.stdout
350 orig_stderr = sys.stderr
351 sys.stdout = string_stdout
M-A Ruel 2011/12/19 21:04:23 No need to name the variables, just sys.stdout = c
chrisha 2012/01/06 21:21:28 Done.
352 sys.stderr = string_stderr
353 try:
354 function(*args, **kwargs)
M-A Ruel 2011/12/19 21:04:23 return function(*args, **kwargs)
chrisha 2012/01/06 21:21:28 Done.
355 finally:
356 sys.stdout = orig_stdout
357 sys.stderr = orig_stderr
358 return
M-A Ruel 2011/12/19 21:04:23 remove
chrisha 2012/01/06 21:21:28 Done.
359
360
361 def InstallPackage(url_or_req, site_dir):
362 """Installs a package to a site directory.
363
364 |site_dir| must exist and already be an active site directory. setuptools
365 must in the path. Uses easy_install which may involve a download from
366 pypi.python.org, so this also requires network access.
367
368 Args:
369 url_or_req: the package to install, expressed as an URL (may be local),
370 or a requirement string.
371 site_dir: the site directory in which to install it.
372
373 Raises:
374 InstallError: if installation fails for any reason.
375 """
376 args = ['--quiet', '--install-dir', site_dir, '--exclude-scripts',
377 '--always-unzip', '--no-deps', url_or_req]
378
379 # The easy_install script only calls SystemExit if something goes wrong.
380 # Otherwise, it falls through returning None.
381 try:
382 import setuptools.command.easy_install
383 _CaptureStdStreams(setuptools.command.easy_install.main, args)
384 except (ImportError, SystemExit), e:
385 # Re-raise the error, preserving the stack trace and message.
386 raise InstallError, sys.exc_info()[1], sys.exc_info()[2]
M-A Ruel 2011/12/19 21:04:23 same everywhere
387
388
389 def _RunInSubprocess(pycode):
390 """Launches a python subprocess with the provided code.
391
392 The subprocess will be launched with the same stdout and stderr. The
393 subprocess will use the same instance of python as is currently running,
394 passing |pycode| as arguments to this script. |pycode| will be interpreted
395 as python code in the context of this module.
396
397 Args:
398 pycode: the statement to execute.
399
400 Returns:
401 True if the subprocess returned 0, False if it returned an error.
402 """
403 result = subprocess.call([sys.executable, __file__, pycode])
M-A Ruel 2011/12/19 21:04:23 return not subprocess.call(...
chrisha 2012/01/06 21:21:28 Done.
404 return result == 0
405
406
407 def _LoadSetupToolsFromEggAndInstall(egg_path):
408 """Loads setuptools from the provided egg, and installs it to SITE_DIR.
409
410 Args:
411 egg_path: the path to the downloaded egg.
412
413 Returns:
414 True on success, False on failure.
415 """
416 AddToPythonPath(egg_path)
417
418 try:
419 # Import setuptools and ensure it comes from the EGG.
420 import setuptools
421 if not ModuleIsFromPackage(setuptools, egg_path):
422 raise ImportError()
423 except ImportError:
424 print ' Unable to import downloaded package!'
425 return False
426
427 try:
428 print ' Using setuptools to install itself ...'
429 InstallPackage(egg_path, SITE_DIR)
430 except InstallError:
431 print ' Unable to install setuptools!'
432 return False
433
434 return True
435
436
437 def BootstrapSetupTools():
438 """Bootstraps the runtime with setuptools.
M-A Ruel 2011/12/19 21:04:23 It'd be nice if it was documented that it's meant
chrisha 2012/01/06 21:21:28 Done.
439
440 Will try to import setuptools directly. If not found it will attempt to
441 download it and load it from there. If the download is successful it will
442 then use setuptools to install itself in the site directory.
443
444 Returns:
445 Returns True if 'import setuptools' will succeed, False otherwise.
446 """
447 AddSiteDirectory(SITE_DIR)
448
449 # Check if setuptools is already available. If so, we're done.
450 try:
451 import setuptools
452 return True
453 except ImportError:
454 pass
455
456 print 'Bootstrapping setuptools ...'
457
458 CreateOrAddSiteDirectory(SITE_DIR)
459
460 # Download the egg to a temp directory.
461 dest_dir = tempfile.mkdtemp('depot_tools')
462 path = None
463 try:
464 package = Package(*SETUPTOOLS)
465 print ' Downloading %s ...' % package.GetFilename()
466 path = package.DownloadEgg(dest_dir)
467 except Error:
468 print ' Download failed!'
469 return False
470
471 try:
472 # Load the downloaded egg, and install it to the site directory. Do this
473 # in a subprocess so as not to pollute this runtime.
474 pycode = '_LoadSetupToolsFromEggAndInstall(%s)' % repr(path)
475 if not _RunInSubprocess(pycode):
476 raise Error
477
478 # Reload our site directory, which should now contain setuptools.
479 AddSiteDirectory(SITE_DIR)
480
481 # Try and import setupttols
482 import setuptools
483 except ImportError:
484 print ' Unable to import setuptools!'
485 return False
486 except Error:
487 # This happens if RunInSubProcess fails, and the appropriate error has
488 # already been written.
489 return False
490 finally:
491 # Delete the temp directory.
492 shutil.rmtree(dest_dir)
493
494 return True
495
496
497 def _GetModTime(path):
498 """Gets the file modification time associated with the given file.
499
500 If the file does not exist, returns 0.
501
502 Args:
503 path: the file to stat.
504
505 Returns:
506 The last modification time of |path| in seconds since epoch, or 0 if
507 |path| does not exist.
508 """
509 try:
510 stat = os.stat(path)
M-A Ruel 2011/12/19 21:04:23 return os.stat(path).st_mtime
511 return stat.st_mtime
512 except:
513 # This error is different depending on the OS.
514 return 0
515
516
517 def _SiteDirectoryIsUpToDate():
518 return _GetModTime(LAST_ROLLED) > _GetModTime(__file__)
519
520
521 def UpdateSiteDirectory():
522 """Installs the packages from PACKAGES if they are not already installed.
523
524 At this point we must have setuptools in the site directory.
525
526 Returns:
527 True on success, False otherwise.
528 """
529 if _SiteDirectoryIsUpToDate():
530 return True
531
532 try:
533 AddSiteDirectory(SITE_DIR)
534 import pkg_resources
535
536 # Determine if any packages actually need installing.
537 missing_packages = []
538 for package in [SETUPTOOLS] + list(PACKAGES):
539 pkg = Package(*package)
540 req = pkg.GetAsRequirementString()
541
542 # It may be that this package is already available in the site
543 # directory. If so, we can skip past it without trying to install it.
544 dist = pkg_resources.working_set.find(
545 pkg_resources.Requirement.parse(req))
546 if dist:
547 continue
548
549 missing_packages.append(pkg)
550
551 # Install the missing packages.
552 if missing_packages:
553 print 'Updating python packages ...'
554 for pkg in missing_packages:
555 print ' Installing %s ...' % pkg.GetFilename()
556 InstallPackage(pkg.GetAsRequirementString(), SITE_DIR)
557
558 # Touch the status file so we know that we're up to date next time.
559 open(LAST_ROLLED, 'wb')
560 except InstallError, e:
561 print ' Installation failed: %s' % str(e)
562 return False
563
564 return True
565
566
567 def SetupSiteDirectory():
568 """Sets up the site directory, bootstrapping setuptools if necessary.
569
570 If this finishes successfully then SITE_DIR will exist and will contain
571 the appropriate version of setuptools and all of the packages listed in
572 PACKAGES.
573
574 This is the main workhorse of this module. Calling this will do everything
575 necessary to ensure that you have the desired packages installed in the
576 site directory, and the site directory enabled in this process.
577
578 Returns:
579 True on success, False on failure.
580 """
581 if _SiteDirectoryIsUpToDate():
582 AddSiteDirectory(SITE_DIR)
583 return True
584
585 if not _RunInSubprocess('BootstrapSetupTools()'):
586 return False
587
588 if not _RunInSubprocess('UpdateSiteDirectory()'):
589 return False
590
591 # Process the site directory so that the packages within it are available
592 # for import.
593 AddSiteDirectory(SITE_DIR)
594
595 return True
596
597
598 def CanImportFromSiteDirectory(package_name):
599 """Determines if the given package can be imported from the site directory.
600
601 Args:
602 package_name: the name of the package to import.
603
604 Returns:
605 True if 'import package_name' will succeed and return a module from the
606 site directory, False otherwise.
607 """
608 try:
609 exec('import %s' % package_name)
M-A Ruel 2011/12/19 21:04:23 __import__(package_name)
chrisha 2012/01/06 21:21:28 Well, you learn something new everyday! Done.
610 except ImportError:
611 return False
612
613 result = False
614 exec('result = ModuleIsFromPackage(%s, SITE_DIR)' % package_name)
M-A Ruel 2011/12/19 21:04:23 ?? result = ModuleIsFromPackage(package_name, SITE
chrisha 2012/01/06 21:21:28 That's the intended result. Made more clear by usi
615 return result
616
617
618 def Main():
619 """The main entry for the package management script.
620
621 If no arguments are provided simply runs SetupSiteDirectory. If arguments
622 have been passed we execute the first argument as python code in the
623 context of this module. This mechanism is used to during the bootstrap
624 process so that the main instance of Python does not have its runtime
625 polluted by various intermediate packages and imports.
626
627 Returns:
628 0 on success, 1 otherwise.
629 """
630 if len(sys.argv) == 2:
631 result = False
M-A Ruel 2011/12/19 21:04:23 result = 0
632 exec('result = %s' % sys.argv[1])
633 return 0 if result else 1
M-A Ruel 2011/12/19 21:04:23 return result and have the function return relevan
chrisha 2012/01/06 21:21:28 For consistency, I made all functions return True/
634 else:
635 return 0 if SetupSiteDirectory() else 1
636
637
638 if __name__ == '__main__':
639 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