root/branches/mk/cheesecake/cheesecake_index.py

Revision 77, 43.7 kB (checked in by mk, 7 years ago)

Added appropriate handling of one-file packages.

  • Property svn:executable set to
Line 
1 #!/usr/bin/env python
2 """
3 Cheesecake: How tasty is your code?
4
5 The idea of the Cheesecake project is to rank Python packages
6 based on various empiric "kwalitee" factors, such as:
7
8         * whether the package can be downloaded
9         * whether the package can be unpacked
10         * whether the package can be installed into an alternate directory
11         * existence of certain files such as README, INSTALL, LICENSE, setup.py etc.
12         * existence of certain directories such as doc, test, demo, examples
13         * percentage of modules/functions/classes/methods with docstrings
14         * percentage of functions/methods that are unit tested
15         * average pylint score for all non-test and non-demo modules
16         * whether the package can be unpacked
17         * whether the package can be installed into an alternate directory
18 """
19
20 import os, sys, re, shutil
21 import tempfile
22 from optparse import OptionParser
23 from urllib import urlretrieve
24 from urlparse import urlparse
25 from math import ceil
26
27 from util import pad_with_dots, pad_left_spaces, pad_right_spaces, pad_msg, pad_line
28 from util import run_cmd, command_successful
29 from util import unzip_package, untar_package
30 from util import mkdirs
31 from util import StdoutRedirector
32 import logger
33 from config import get_pkg_config
34 from codeparser import CodeParser
35
36 __docformat__ = 'reStructuredText en'
37
38
39 ################################################################################
40 ## Helpers.
41 ################################################################################
42
43 def isiterable(obj):
44     """Check whether object is iterable.
45
46     >>> isiterable([1,2,3])
47     True
48     >>> isiterable("string")
49     True
50     >>> isiterable(object)
51     False
52     """
53     return hasattr(obj, '__iter__') or isinstance(obj, basestring)
54
55 def has_extension(filename, ext):
56     """Check if filename has given extension.
57
58     >>> has_extension("foobar.py", ".py")
59     True
60     >>> has_extension("foo.bar.py", ".py")
61     True
62     >>> has_extension("foobar.pyc", ".py")
63     False
64     """
65     return os.path.splitext(filename)[1] == ext
66
67 def discover_file_type(filename):
68     """Discover type of a file according to its name and its parent directory.
69
70     Currently supported file types:
71         * pyc
72         * pyo
73         * module: .py files of an application
74         * demo: .py files for documentation/demonstration purposes
75         * test: .py files used for testing
76         * special: .py file for special purposes
77
78     :Note: This function only check file's name, and doesn't touch the
79            filesystem. If you have to, check if file exists by yourself.
80
81     >>> discover_file_type('module.py')
82     'module'
83     >>> discover_file_type('./setup.py')
84     'special'
85     >>> discover_file_type('some/directory/junk.pyc')
86     'pyc'
87     """
88     dirs = filename.split(os.path.sep)
89     dirs, filename = dirs[:-1], dirs[-1]
90
91     if filename in ["setup.py", "ez_setup.py", "__pkginfo__.py"]:
92         return 'special'
93
94     if has_extension(filename, ".pyc"):
95         return 'pyc'
96     if has_extension(filename, ".pyo"):
97         return 'pyo'
98     if has_extension(filename, ".py"):
99         for dir in dirs:
100             if dir in ['test', 'tests']:
101                 return 'test'
102             elif dir in ['docs', 'demo', 'example']:
103                 return 'demo'
104         return 'module'
105
106 def get_files_of_type(file_list, file_type):
107     """Return files from `file_list` that match given `file_type`.
108
109     >>> file_list = ['test/test_foo.py', 'setup.py', 'README', 'test/test_bar.py']
110     >>> get_files_of_type(file_list, 'test')
111     ['test/test_foo.py', 'test/test_bar.py']
112     """
113     return filter(lambda x: discover_file_type(x) == file_type, file_list)
114
115 def get_method_arguments(method):
116     """Return tuple of arguments for given method, excluding self.
117
118     >>> class Class:
119     ...     def method(s, arg1, arg2, other_arg):
120     ...         pass
121     >>> get_method_arguments(Class.method)
122     ('arg1', 'arg2', 'other_arg')
123     """
124     return method.func_code.co_varnames[1:method.func_code.co_argcount]
125
126 def get_attributes(obj, names):
127     """Return attributes dictionary with keys from `names`.
128
129     Object is queried for each attribute name, if it doesn't have this
130     attribute, default value None will be returned.
131
132     >>> class Class:
133     ...     pass
134     >>> obj = Class()
135     >>> obj.attr = True
136     >>> obj.value = 13
137     >>> obj.string = "Hello"
138
139     >>> d = get_attributes(obj, ['attr', 'string', 'other'])
140     >>> d == {'attr': True, 'string': "Hello", 'other': None}
141     True
142     """
143     attrs = {}
144
145     for name in names:
146         attrs[name] = getattr(obj, name, None)
147
148     return attrs
149
150 def camel2underscore(name):
151     """Convert name from CamelCase to underscore_name.
152
153     >>> camel2underscore('CamelCase')
154     'camel_case'
155     >>> camel2underscore('already_underscore_name')
156     'already_underscore_name'
157     >>> camel2underscore('BigHTMLClass')
158     'big_html_class'
159     >>> camel2underscore('')
160     ''
161     """
162     if name and name[0].upper:
163         name = name[0].lower() + name[1:]
164
165     def capitalize(match):
166         string = match.group(1).lower().capitalize()
167         return string[:-1] + string[-1].upper()
168
169     def underscore(match):
170         return '_' + match.group(1).lower()
171
172     name = re.sub(r'([A-Z]+)', capitalize, name)
173     return re.sub(r'([A-Z])', underscore, name)
174
175 def index_class_to_name(clsname):
176     """Covert index class name to index name.
177
178     >>> index_class_to_name("IndexDownload")
179     'download'
180     >>> index_class_to_name("IndexUnitTests")
181     'unit_tests'
182     >>> index_class_to_name("IndexPyPIDownload")
183     'py_pi_download'
184     """
185     return camel2underscore(clsname.replace('Index', '', 1))
186
187 def is_empty(path):
188     """Returns True if file or directory pointed by `path` is empty.
189     """
190     if os.path.isfile(path) and os.path.getsize(path) == 0:
191         return True
192     if os.path.isdir(path) and os.listdir(path) == []:
193         return True
194
195     return False
196
197 def strip_dir_part(path, root):
198     """Strip `root` part from `path`.
199
200     >>> strip_dir_part('/home/ruby/file', '/home')
201     'ruby/file'
202     >>> strip_dir_part('/home/ruby/file', '/home/')
203     'ruby/file'
204     >>> strip_dir_part('/home/ruby/', '/home')
205     'ruby/'
206     >>> strip_dir_part('/home/ruby/', '/home/')
207     'ruby/'
208     """
209     path = path.replace(root, '', 1)
210
211     if path.startswith(os.path.sep):
212         path = path[1:]
213
214     return path
215
216 def get_files_dirs_list(root):
217     """Return list of all files and directories below `root`.
218
219     Root directory is excluded from files/directories paths.
220     """
221     files = []
222     directories = []
223
224     for dirpath, dirnames, filenames in os.walk(root):
225         dirpath = strip_dir_part(dirpath, root)
226         files.extend(map(lambda x: os.path.join(dirpath, x), filenames))
227         directories.extend(map(lambda x: os.path.join(dirpath, x), dirnames))
228
229     return files, directories
230
231 ################################################################################
232 ## Main index class.
233 ################################################################################
234
235 class NameSetter(type):
236     def __init__(cls, name, bases, dict):
237         if 'name' not in dict:
238             setattr(cls, 'name', index_class_to_name(name))
239
240 def make_indices_dict(indices):
241     indices_dict = {}
242     for index in indices:
243         indices_dict[index.name] = index
244     return indices_dict
245
246 class Index(object):
247     """Class describing one index.
248
249     Use it as a container index or subclass to create custom indices.
250
251     During class initialization, special attribute `name` is magically
252     set based on class name. See `index_class_to_name` and `NameSetter`
253     definitions for details.
254     """
255     __metaclass__ = NameSetter
256
257     subindices = None
258
259     name = "unnamed"
260     value = -1
261     details = ""
262
263     def __init__(self, indices=[]):
264         if not self.subindices:
265             self.subindices = []
266
267         # Create dictionary for fast reference.
268         self._indices_dict = make_indices_dict(self.subindices)
269
270         for index in indices:
271             self.add_subindex(index)
272
273         self._compute_arguments = get_method_arguments(self.compute)
274
275     def _iter_indices(self):
276         """Iterate over each subindex and yield their values.
277         """
278         for index in self.subindices:
279             # Pass Cheesecake instance to other indices.
280             yield index.compute_with(self.cheesecake)
281             # Print index info after computing.
282             if not self.cheesecake.quiet:
283                 index.print_info()
284
285     def compute_with(self, cheesecake):
286         """Take given Cheesecake instance and compute index value.
287         """
288         self.cheesecake = cheesecake
289         return self.compute(**get_attributes(cheesecake, self._compute_arguments))
290
291     def compute(self):
292         """Compute index value and return it.
293
294         By default this method computes sum of all subindices. Override this
295         method when subclassing for different behaviour.
296
297         Parameters to this function are dynamically prepared with use of
298         `get_attributes` function.
299
300         :Warning: Don't use *args and **kwds arguments for this method.
301         """
302         self.value = sum(self._iter_indices())
303         return self.value
304
305     def _get_max_value(self):
306         if self.subindices:
307             return sum(map(lambda index: index.max_value,
308                            self.subindices))
309         return 0
310
311     max_value = property(_get_max_value)
312
313     def add_subindex(self, index):
314         """Add subindex.
315
316         :Parameters:
317           `index` : Index instance
318               Index instance for inclusion.
319         """
320         if not isinstance(index, Index):
321             raise ValueError("subindex have to be instance of Index")
322
323         self.subindices.append(index)
324         self._indices_dict[index.name] = index
325
326     def remove_subindex(self, index_name):
327         """Remove subindex (refered by name).
328
329         :Parameters:
330           `index` : Index name
331               Index name to be removed.
332         """
333         index = self._indices_dict[index_name]
334         self.subindices.remove(index)
335         del self._indices_dict[index_name]
336
337     def _print_info_one(self):
338         print "%s  (%s)" % (pad_msg(self.name, self.value), self.details)
339
340     def _print_info_many(self):
341         max_value = self.max_value
342         if max_value == 0:
343             return
344
345         percentage = int(ceil(float(self.value) / float(max_value) * 100))
346         print pad_line("-")
347
348         print pad_msg("%s INDEX (ABSOLUTE)" % self.name, self.value)
349         msg = pad_msg("%s INDEX (RELATIVE)" % self.name, percentage)
350         msg += "  (%d out of a maximum of %d points is %d%%)" %\
351              (self.value, max_value, percentage)
352
353         print msg
354         print
355
356     def print_info(self):
357         """Print index name padded with dots, followed by value and details.
358         """
359         if self.subindices:
360             self._print_info_many()
361         else:
362             self._print_info_one()
363
364     def __getitem__(self, name):
365         return self._indices_dict[name]
366
367 ################################################################################
368 ## Index that computes scores based on files and directories.
369 ################################################################################
370
371 class OneOf(object):
372     def __init__(self, *possibilities):
373         self.possibilities = possibilities
374     def __str__(self):
375         return 'one of %s' % (self.possibilities,)
376
377 def WithOptionalExt(name, extensions):
378     """Handy way of writing Cheese rules for files with extensions.
379
380     Instead of writing:
381         >>> one_of = OneOf('readme', 'readme.html', 'readme.txt')
382
383     Write this:
384         >>> opt_ext = WithOptionalExt('readme', ['html', 'txt'])
385
386     It means the same! (representation have a meaning)
387         >>> str(one_of) == str(opt_ext)
388         True
389     """
390     possibilities = [name]
391     possibilities.extend(map(lambda x: name + '.' + x, extensions))
392
393     return OneOf(*possibilities)
394
395 def Doc(name):
396     return WithOptionalExt(name, ['html', 'txt'])
397
398 class FilesIndex(Index):
399     _used_rules = []
400
401     def _compute_from_rules(self, files_list, package_dir, files_rules):
402         self._used_rules = []
403         files_count = 0
404         value = 0
405
406         for filename in files_list:
407             if not is_empty(os.path.join(package_dir, filename)):
408                 score = self.get_score(os.path.basename(filename), files_rules)
409                 if score != 0:
410                     value += score
411                     files_count += 1
412
413         return files_count, value
414
415     def get_score(self, name, specs):
416         for entry, value in specs.iteritems():
417             if self.match_filename(name, entry):
418                 self.cheesecake.log.debug("%d points entry found: %s (%s)" % \
419                                           (value, name, entry))
420                 return value
421
422         return 0
423
424     def match_filename(self, name, rule):
425         """Check if `name` matches given `rule`.
426         """
427         def equal(x, y):
428             x_root, x_ext = os.path.splitext(x)
429             y_root, y_ext = os.path.splitext(y.lower())
430             if x_root in [y_root.lower(), y_root.upper(), y_root.capitalize()] \
431                    and x_ext in [y_ext.lower(), y_ext.upper()]:
432                 return True
433             return False
434
435         if rule in self._used_rules:
436             return False
437
438         if isinstance(rule, basestring):
439             if equal(name, rule):
440                 self._used_rules.append(rule)
441                 return True
442         elif isinstance(rule, OneOf):
443             for poss in rule.possibilities:
444                 if self.match_filename(name, poss):
445                     self._used_rules.append(rule)
446                     return True
447
448         return False
449
450 ################################################################################
451 ## Installability index.
452 ################################################################################
453
454 class IndexUrlDownload(Index):
455     max_value = 25
456
457     def compute(self, downloaded_from_url, package, url):
458         if downloaded_from_url:
459             self.details = "downloaded package %s from URL %s"  % (package, url)
460             self.value = self.max_value
461         else:
462             self.value = 0
463
464         return self.value
465
466 class IndexUnpack(Index):
467     max_value = 25
468
469     def compute(self, unpacked):
470         if unpacked:
471             self.details = "package unpacked successfully"
472             self.value = self.max_value
473         else:
474             self.value = 0
475
476         return self.value
477
478 class IndexUnpackDir(Index):
479     max_value = 15
480
481     def compute(self, unpack_dir, original_package_name):
482         self.details = "unpack directory is " + unpack_dir
483
484         if original_package_name:
485             self.details += " instead of the expected " + original_package_name
486             self.value = 0
487         else:
488             self.details += " as expected"
489             self.value = self.max_value
490
491         return self.value
492
493 class IndexSetupPy(FilesIndex):
494     name = "setup.py"
495     max_value = 25
496
497     files_rules = {
498         'setup.py': 25,
499     }
500
501     def compute(self, files_list, package_dir):
502         setup_py_found, self.value = self._compute_from_rules(files_list, package_dir, self.files_rules)
503
504         if setup_py_found:
505             self.details = "setup.py found"
506         else:
507             self.details = "setup.py not found"
508
509         return self.value
510
511 class IndexInstall(Index):
512     max_value = 50
513
514     def compute(self, installed, sandbox_install_dir):
515         if installed:
516             self.details = "package installed in %s" % sandbox_install_dir
517             self.value = self.max_value
518         else:
519             self.details = "could not install package in %s" % sandbox_install_dir
520             self.value = 0
521
522         return self.value
523
524 class IndexPyPIDownload(Index):
525     max_value = 50
526     distance_penalty = -5
527
528     def compute(self, package, found_on_cheeseshop, distance_from_pypi, download_url):
529         if download_url:
530             self.value = self.max_value
531
532             self.details = "downloaded package " + package
533
534             if not found_on_cheeseshop:
535                 self.value += (distance_from_pypi - 1) * self.distance_penalty
536
537                 if distance_from_pypi:
538                     self.details += " following %d link" % distance_from_pypi
539                     if distance_from_pypi > 1:
540                         self.details += "s"
541                         self.details += " from PyPI"
542                     else:
543                         self.details += " from " + download_url
544             else:
545                 self.details += " directly from the Cheese Shop"
546         else:
547             self.value = 0
548
549         return self.value
550
551 class IndexGeneratedFiles(Index):
552     generated_files_penalty = -20
553
554     def compute(self, files_list):
555         self.value = 0
556
557         pyc_files = len(get_files_of_type(files_list, 'pyc'))
558         pyo_files = len(get_files_of_type(files_list, 'pyo'))
559
560         if pyc_files > 0 or pyo_files > 0:
561             self.value += self.generated_files_penalty
562
563         self.details = "%d .pyc and %d .pyo files found" % \
564                                   (pyc_files, pyo_files)
565
566         return self.value
567
568 class IndexInstallability(Index):
569     name = "INSTALLABILITY"
570
571     subindices = [
572         IndexUnpack(),
573         IndexUnpackDir(),
574         IndexSetupPy(),
575         IndexInstall(),
576         IndexGeneratedFiles(),
577     ]
578
579 ################################################################################
580 ## Documentation index.
581 ################################################################################
582
583 class IndexRequiredFiles(FilesIndex):
584     cheese_files = {
585         Doc('readme'): 30,
586         OneOf(Doc('license'), Doc('copying')): 30,
587
588         OneOf(Doc('announce'), Doc('changelog')): 20,
589         Doc('install'): 20,
590
591         Doc('authors'): 10,
592         Doc('faq'): 10,
593         Doc('news'): 10,
594         Doc('thanks'): 10,
595         Doc('todo'): 10,
596     }
597
598     cheese_dirs = {
599         OneOf('doc', 'docs'): 30,
600         OneOf('test', 'tests'): 30,
601
602         'demo': 10,
603         OneOf('example', 'examples'): 10,
604     }
605
606     max_value = sum(cheese_files.values() + cheese_dirs.values())
607
608     def compute(self, files_list, dirs_list, package_dir):
609         files_count, files_value = self._compute_from_rules(files_list, package_dir, self.cheese_files)
610         dirs_count, dirs_value = self._compute_from_rules(dirs_list, package_dir, self.cheese_dirs)
611
612         self.value = files_value + dirs_value
613
614         self.details = "%d files and %d required directories found" % \
615                        (files_count, dirs_count)
616
617         return self.value
618
619 class IndexDocstrings(Index):
620     max_value = 100
621
622     def compute(self, object_cnt, docstring_cnt):
623         percent = 0
624         if object_cnt > 0:
625             percent = float(docstring_cnt)/float(object_cnt)
626
627         # Scale the result.
628         self.value = int(ceil(percent * self.max_value))
629
630         self.details = "found %d/%d=%.2f%% objects with docstrings" %\
631                  (docstring_cnt, object_cnt, percent*100)
632
633         return self.value
634
635 class IndexFormattedDocstrings(Index):
636     max_value = 50
637
638     def compute(self, object_cnt, docformat_cnt):
639         percent = 0
640         if object_cnt > 0:
641             percent = float(docformat_cnt)/float(object_cnt)
642
643         # Scale the result.
644         # We give 20p for 25% of formatted docstrings, 35p for 50% and 50p for 75%.
645         self.value = 0
646         if percent > 0.75:
647             self.value = 50
648         elif percent > 0.50:
649             self.value = 35
650         elif percent > 0.25:
651             self.value = 20
652
653         self.details = "found %d/%d=%.2f%% objects with formatted docstrings" %\
654                  (docformat_cnt, object_cnt, percent*100)
655
656         return self.value
657
658 class IndexDocumentation(Index):
659     name = "DOCUMENTATION"
660
661     subindices = [
662         IndexRequiredFiles(),
663         IndexDocstrings(),
664         IndexFormattedDocstrings(),
665     ]
666
667 ################################################################################
668 ## Code "kwalitee" index.
669 ################################################################################
670
671 class IndexUnitTests(Index):
672     """Compute unittest index as percentage of methods/functions
673     that are exercised in unit tests.
674     """
675     max_value = 50
676
677     def compute(self, files_list, functions, package_dir):
678         unittest_cnt = 0
679         self.functions_tested = {}
680
681         for testfile in get_files_of_type(files_list, 'test'):
682             fullpath = os.path.join(package_dir, testfile)
683             code = CodeParser(fullpath, self.cheesecake.log.debug)
684
685             func_called = code.functions_called()
686
687             for func in func_called:
688                 self.functions_tested[func] = 1
689
690         for funcname in functions:
691             if self.is_unit_tested(funcname):
692                 unittest_cnt += 1
693                 self.log.cheesecake.debug("%s is unit tested" % funcname)
694
695         percent = 0
696         if len(functions) > 0:
697             percent = float(unittest_cnt)/float(len(functions))
698
699         # Scale the result.
700         self.value = int(ceil(percent * self.max_value))
701
702         self.details = "found %d/%d=%.2f%% unit tested methods/functions." %\
703                  (unittest_cnt, len(functions), percent*100)
704
705         return self.value
706
707     def is_unit_tested(self, funcname):
708         elem = funcname.split(".")
709         n1 = elem[-1]
710         n2 = ""
711         if len(elem) > 1:
712             n2 = elem[-2] + "." + elem[-1]
713         for key in self.functions_tested.keys():
714             if key.startswith(n1) or (n2 and key.startswith(n2)):
715                 return True
716         return False
717
718 class IndexPyLint(Index):
719     """Compute pylint index as average of positive pylint scores obtained for
720     the Python files identified in the package.
721     """
722     name = "pylint"
723     max_value = 50
724
725     disabled_messages = [
726         'W0403', # relative import
727         'W0406', # importing of self
728     ]
729     pylint_args = ' '.join(map(lambda x: '--disable-msg=%s' % x, disabled_messages))
730
731     def compute(self, files_list, package_dir):
732         self.value = 0
733
734         # Try to run the pylint script
735         if not command_successful("pylint --version"):
736             self.details = "pylint not properly installed"
737             return self.value
738
739         pylint_value = 0
740         cnt = 0
741         for pyfile in get_files_of_type(files_list, 'module'):
742             fullpath = os.path.join(package_dir, pyfile)
743             path, filename = os.path.split(fullpath)
744             module, ext = os.path.splitext(filename)
745
746             self.cheesecake.log.debug("Running pylint on file " + fullpath)
747             rc, output = run_cmd("pylint %s %s" % (self.pylint_args, fullpath))
748             if rc:
749                 self.cheesecake.log.debug("encountered an error (%d)." % rc)
750                 continue
751
752             score_line = output.split("\n")[-3]
753             s = re.search(r" (\d+\.\d+)/10", score_line)
754             # We only take positive scores into account
755             if s:
756                 score = s.group(1)
757                 if score == "0.00":
758                     self.cheesecake.log.debug("ignoring 0.00 score.")
759                     continue
760                 else:
761                     self.cheesecake.log.debug("pylint score for module %s: %s" % (module, score))
762                 pylint_value += float(score)
763                 cnt += 1
764
765         avg_score = 0
766         if cnt:
767             avg_score = float(pylint_value)/float(cnt)
768
769         self.value = int(ceil(avg_score/10.0 * self.max_value))
770         self.details = "average pylint score is %.2f out of 10" % avg_score
771
772         return self.value
773
774 class IndexCodeKwalitee(Index):
775     name = "CODE KWALITEE"
776
777     subindices = [
778         IndexPyLint(),
779         # IndexUnitTests(), TODO
780     ]
781
782 ################################################################################
783 ## Main Cheesecake class.
784 ################################################################################
785
786 class CheesecakeError(Exception):
787     """Custom exception class for Cheesecake-specific errors.
788     """
789     pass
790
791
792 class CheesecakeIndex(Index):
793     name = "Cheesecake"
794     subindices = [
795         IndexInstallability(),
796         IndexDocumentation(),
797         IndexCodeKwalitee(),
798     ]
799
800
801 class Cheesecake(object):
802     """
803     Computes 'goodness' of Python packages
804
805     Generates "cheesecake index" that takes into account things like:
806
807         * whether the package can be downloaded
808         * whether the package can be unpacked
809         * whether the package can be installed into an alternate directory
810         * existence of certain files such as README, INSTALL, LICENSE, setup.py etc.
811         * existence of certain directories such as doc, test, demo, examples
812         * percentage of modules/functions/classes/methods with docstrings
813         * percentage of functions/methods that are unit tested
814         * average pylint score for all non-test and non-demo modules
815     """
816     index = CheesecakeIndex()
817
818     def __init__(self, name="", url="", path="", sandbox=None, config=None,
819                 logfile=None, verbose=False, quiet=False):
820         """Initialize critical variables, download and unpack package,
821         walk package tree.
822         """
823         self.name = name
824         self.url = url
825         self.package_path = path
826
827         if not self.name and not self.url and not self.package_path:
828             self.raise_exception("No package name, URL or path specified ... exiting")
829
830         self.sandbox = sandbox or tempfile.mkdtemp(prefix='cheesecake')
831         if not os.path.isdir(self.sandbox):
832             os.mkdir(self.sandbox)
833
834         self.config = config
835         self.verbose = verbose
836         self.quiet = quiet
837
838         self.package_types = {
839             "tar.gz": untar_package,
840             "tgz": untar_package,
841             "zip": unzip_package,
842             "egg": unzip_package,
843         }
844
845         self.sandbox_pkg_file = ""
846         self.sandbox_pkg_dir = ""
847         self.sandbox_install_dir = ""
848
849         # Include indices revelant to current situation.
850         if self.name:
851             self.index["INSTALLABILITY"].add_subindex(IndexPyPIDownload())
852         if self.url:
853             self.index["INSTALLABILITY"].add_subindex(IndexUrlDownload())
854
855         # Get package name.
856         self.determine_pkg_name()
857
858         # Configure logging as soon as possible.
859         self.configure_logging(logfile)
860
861         # Get package,
862         self.retrieve_pkg()
863
864         # Get package name and type.
865         self.package_name, self.package_type = self.get_package_name_and_type(self.package)
866         self.log.debug("Package name: " + self.package_name)
867         self.log.debug("Package type: " + self.package_type)
868
869         # Unpack package and list its files.
870         self.unpack_pkg()
871         self.walk_pkg()
872
873         # Install package.
874         self.install_pkg()
875
876         # When checking an egg exclude irrelevant indices.
877         if self.package_type == 'egg':
878             self.index["INSTALLABILITY"].remove_subindex('setup.py')
879             self.index["INSTALLABILITY"].remove_subindex('unpack_dir')
880             self.index["INSTALLABILITY"].remove_subindex('generated_files')
881
882     def raise_exception(self, msg):
883         """Cleanup, print error message and raise CheesecakeError.
884
885         Don't use logging, since it can be called before logging has been setup.
886         """
887         self.cleanup(remove_log_file=False)
888
889         msg += "\nDetailed info available in log file %s" % self.logfile
890
891         raise CheesecakeError(msg)
892
893     def cleanup(self, remove_log_file=True):
894         """Delete temporary directories and files that were created
895         in the sandbox. At the end delete the sandbox itself.
896         """
897         if os.path.isfile(self.sandbox_pkg_file):
898             self.log("Removing file %s" % self.sandbox_pkg_file)
899             os.unlink(self.sandbox_pkg_file)
900
901         def delete_dir(dirname):
902             "Delete directory recursively and generate log message."
903             if os.path.isdir(dirname):
904                 self.log("Removing directory %s" % dirname)
905                 shutil.rmtree(dirname)
906
907         delete_dir(self.sandbox)
908
909         if remove_log_file:
910             os.unlink(os.path.join(self.sandbox, self.logfile))
911
912     def set_defaults(self):
913         """Set default values for variables that can also be defined
914         in the config file.
915         """
916         pass
917
918     def get_config(self, config_dir=None):
919         """Retrieve values from configuration file.
920         """
921         pass
922
923     def determine_pkg_name(self):
924         if self.name:
925             self.package = self.name
926             self.short_pkg_name = self.name
927         elif self.package_path:
928             self.package = self.get_package_from_path(self.package_path)
929         else:
930             self.package = self.get_package_from_url()
931
932     def get_package_from_url(self):
933         """Use ``urlparse`` to obtain package path from URL.
934         """
935         (scheme,location,path,param,query,fragment_id) = urlparse(self.url)
936         return self.get_package_from_path(path)
937
938     def get_package_from_path(self, path):
939         """Get package name as file portion of path.
940         """
941         dir, file = os.path.split(path)
942         self.short_pkg_name = file
943         for package_type in self.package_types:
944             s = re.search("(.+)\.%s" % package_type, file)
945             if s:
946                 self.short_pkg_name = s.group(1)
947                 break
948         return file
949
950     def configure_logging(self, logfile=None):
951         """Default settings for logging.
952
953         If verbose, log goes to console, else it goes to logfile.
954         log.debug and log.info goes to logfile.
955         log.warn and log.error go to both logfile and stdout.
956         """
957         if logfile:
958             self.logfile = logfile
959         else:
960             self.logfile = os.path.join(tempfile.gettempdir(), self.short_pkg_name + ".log")
961
962         logger.setconsumer('logfile', open(str(self.logfile), 'w', buffering=1))
963         logger.setconsumer('console', logger.STDOUT)
964         logger.setconsumer('null', None)
965
966         if self.verbose:
967             self.log = logger.MultipleProducer('cheesecake console')
968         else:
969             self.log = logger.MultipleProducer('cheesecake logfile')
970
971         self.log.info = logger.MultipleProducer('cheesecake logfile')
972         self.log.debug = logger.MultipleProducer('cheesecake logfile')
973         self.log.warn = logger.MultipleProducer('cheesecake console')
974         self.log.error = logger.MultipleProducer('cheesecake console')
975
976     def retrieve_pkg(self):
977         if self.name:
978             self.get_pkg_from_pypi()
979         elif self.url:
980             self.download_pkg()
981         else:
982             self.copy_pkg()
983
984     def get_pkg_from_pypi(self):
985         """Download package using setuptools utilities.
986
987         :Ivariables:
988           download_url : str
989               URL that package was downloaded from.
990           distance_from_pypi : int
991               How many hops setuptools had to make to download package.
992           found_on_cheeseshop : bool
993               Whenever package has been found on CheeseShop.
994         """
995         self.log.info("Trying to download package %s from PyPI using setuptools utilities" % self.name)
996
997         try:
998             from setuptools.package_index import PackageIndex
999             from pkg_resources import Requirement
1000             from distutils import log
1001             from distutils.errors import DistutilsError
1002
1003         except ImportError, e:
1004             msg = "Error: setuptools is not installed and is required for downloading a package by name\n"
1005             msg += "You can download and process a package by its full URL via the -u or --url option\n"
1006             msg += "Example: python cheesecake.py --url=http://www.mems-exchange.org/software/durus/Durus-3.1.tar.gz"
1007             self.raise_exception(msg)
1008
1009         def drop_setuptools_info(stdout, error=None):
1010             """Drop all setuptools output as INFO.
1011             """
1012             self.log.info("*** Begin setuptools output")
1013             map(self.log.info, stdout.splitlines())
1014             if error:
1015                 self.log.info(str(error))
1016             self.log.info("*** End setuptools output")
1017
1018         try:
1019             # Temporarily set the log verbosity to INFO so we can capture setuptools info messages
1020             old_threshold = log.set_threshold(log.INFO)
1021             pkgindex = PackageIndex()
1022             old_stdout = sys.stdout
1023             sys.stdout = StdoutRedirector()
1024             output = pkgindex.fetch(Requirement.parse(self.name),
1025                                     self.sandbox,
1026                                     force_scan=True,
1027                                     source=False)
1028             captured_stdout = sys.stdout.read_buffer()
1029             sys.stdout = old_stdout
1030             log.set_threshold(old_threshold)
1031
1032         except DistutilsError, e:
1033             # Bring back old stdout.
1034             captured_stdout = sys.stdout.read_buffer()
1035             sys.stdout = old_stdout
1036             log.set_threshold(old_threshold)
1037
1038             drop_setuptools_info(captured_stdout, error)
1039             self.raise_exception("Error: setuptools returned an error: %s\n" % e)
1040
1041         if output is None:
1042             drop_setuptools_info(captured_stdout)
1043             self.raise_exception("Error: Could not find distribution for " + self.name)
1044
1045         # Defaults.
1046         self.download_url = ""
1047         self.distance_from_pypi = 0
1048         self.found_on_cheeseshop = False
1049
1050         for line in captured_stdout.split('\n'):
1051             s = re.search(r"Reading http(.*)", line)
1052             if s:
1053                 inspected_url = s.group(1)
1054                 if not re.search(r"www.python.org\/pypi", inspected_url):
1055                     self.distance_from_pypi += 1
1056                 continue
1057             s = re.search(r"Downloading (.*)", line)
1058             if s:
1059                 self.download_url = s.group(1)
1060                 break
1061
1062         self.sandbox_pkg_file = output
1063         self.package = self.get_package_from_path(output)
1064         self.log.info("Downloaded package %s from %s" % (self.package, self.download_url))
1065
1066         if re.search(r"cheeseshop.python.org", self.download_url):
1067             self.found_on_cheeseshop = True
1068
1069     def download_pkg(self):
1070         """Use ``urllib.urlretrieve`` to download package to file in sandbox dir.
1071         """
1072         #self.log("Downloading package %s from URL %s" % (self.package, self.url))
1073         self.sandbox_pkg_file = os.path.join(self.sandbox, self.package)
1074         try:
1075             downloaded_filename, headers = urlretrieve(self.url, self.sandbox_pkg_file)
1076         except IOError, e:
1077             self.log.error("Error downloading package %s from URL %s"  % (self.package, self.url))
1078             self.raise_exception(str(e))
1079         #self.log("Downloaded package %s to %s" % (self.package, downloaded_filename))
1080
1081         if headers.gettype() in ["text/html"]:
1082             f = open(downloaded_filename)
1083             if re.search("404 Not Found", "".join(f.readlines())):
1084                 f.close()
1085                 self.raise_exception("Got '404 Not Found' error while trying to download package ... exiting")
1086             f.close()
1087
1088         self.downloaded_from_url = True
1089        
1090     def copy_pkg(self):
1091         """Copy package file to sandbox directory.
1092         """
1093         self.sandbox_pkg_file = os.path.join(self.sandbox, self.package)
1094         if not os.path.isfile(self.package_path):
1095             self.raise_exception("%s is not a valid file ... exiting" % self.package_path)
1096         self.log("Copying file %s to %s" % (self.package_path, self.sandbox_pkg_file))
1097         shutil.copyfile(self.package_path, self.sandbox_pkg_file)
1098
1099     def get_package_name_and_type(self, package):
1100         """Return package name and type.
1101
1102         Raise an exception when package type cannot be recognized.
1103         """
1104         for type in self.package_types.keys():
1105             s = re.search(r"(.+)\.%s" % type, package)
1106             if s:
1107                 # Package name is name of package without file extension (ex. twill-7.3).
1108                 return s.group(1), type
1109
1110         msg = "Could not determine package type for package '%s'" % package
1111         msg += "\nCurrently recognized types: " + ", ".join(self.package_types.keys())
1112         self.raise_exception(msg)
1113
1114     def unpack_pkg(self):
1115         """Unpack the package in the sandbox directory.
1116
1117         Check `package_types` attribute for list of currently supported
1118         archive types.
1119
1120         :Ivariables:
1121           original_package_name : str
1122         """
1123         self.sandbox_pkg_dir = os.path.join(self.sandbox, self.package_name)
1124         if os.path.isdir(self.sandbox_pkg_dir):
1125             shutil.rmtree(self.sandbox_pkg_dir)
1126
1127         # Call appropriate function to unpack the package.
1128         unpack = self.package_types[self.package_type]
1129         self.unpack_dir = unpack(self.sandbox_pkg_file, self.sandbox)
1130
1131         if self.unpack_dir is None:
1132             self.raise_exception("Could not unpack package %s ... exiting" % \
1133                                  self.sandbox_pkg_file)
1134
1135         self.unpacked = True
1136
1137         if self.unpack_dir != self.package_name:
1138             self.original_package_name = self.package_name
1139             self.package_name = self.unpack_dir
1140
1141     def walk_pkg(self):
1142         """Get package files and directories.
1143
1144         :Ivariables:
1145           dirs_list : list
1146               List of directories package contains.
1147           docstring_cnt : int
1148               Number of docstrings found in all package objects.
1149           docformat_cnt : int
1150               Number of formatted docstrings found in all package objects.
1151           files_list : list
1152               List of files package contains.
1153           functions : list
1154               List of all functions defined in package sources.
1155           object_cnt : int
1156               Number of documentable objects found in all package modules.
1157           package_dir : str
1158               Path to project directory.
1159         """
1160         self.package_dir = os.path.join(self.sandbox, self.package_name)
1161
1162         self.files_list, self.dirs_list = get_files_dirs_list(self.package_dir)
1163
1164         self.object_cnt = 0
1165         self.docstring_cnt = 0
1166         self.docformat_cnt = 0
1167         self.functions = []
1168
1169         # Parse all application files and count objects
1170         # (modules/classes/functions) and their associated docstrings.
1171         for py_file in get_files_of_type(self.files_list, 'module'):
1172             pyfile = os.path.join(self.package_dir, py_file)
1173             code = CodeParser(pyfile, self.log.debug)
1174
1175             self.object_cnt += code.object_count()
1176             self.docstring_cnt += code.docstring_count()
1177             self.docformat_cnt += code.formatted_docstrings_count
1178             self.functions += code.functions
1179
1180         # Log a bit of debugging info.
1181         self.log.debug("Found %d files: %s." % (len(self.files_list),
1182                                                 ', '.join(self.files_list)))
1183         self.log.debug("Found %d directories: %s." % (len(self.dirs_list),
1184                                                       ', '.join(self.dirs_list)))
1185
1186     def install_pkg(self):
1187         """Verify that package can be installed in alternate directory.
1188
1189         :Ivariables:
1190           installed : bool
1191               Describes whenever package has been succefully installed.
1192         """
1193         self.sandbox_install_dir = os.path.join(self.sandbox, "tmp_install_%s" % self.package_name)
1194
1195         if self.package_type == 'egg':
1196             # Create dummy Python directories.
1197             mkdirs('%s/lib/python2.3/site-packages/' % self.sandbox_install_dir)
1198             mkdirs('%s/lib/python2.4/site-packages/' % self.sandbox_install_dir)
1199
1200             environment = {'PYTHONPATH':
1201                            '%(sandbox)s/lib/python2.3/site-packages/:'\
1202                            '%(sandbox)s/lib/python2.4/site-packages/' % \
1203                            {'sandbox': self.sandbox_install_dir}}
1204             rc, output = run_cmd("easy_install --no-deps --prefix %s %s" % \
1205                                  (self.sandbox_install_dir, self.sandbox_pkg_file),
1206                                  environment)
1207         else:
1208             if os.path.isdir(self.package_name):
1209                 package_dir = os.path.join(self.sandbox, self.package_name)
1210             else:
1211                 package_dir = self.sandbox
1212             cwd = os.getcwd()
1213             os.chdir(package_dir)
1214             rc, output = run_cmd("python setup.py install --root=" + self.sandbox_install_dir)
1215             os.chdir(cwd)
1216
1217         if rc:
1218             self.log('*** Installation failed. Captured output:')
1219             for output_line in output.splitlines():
1220                 self.log(output_line)
1221             self.log('*** End of captured output.')
1222         else:
1223             self.installed = True
1224
1225     def compute_cheesecake_index(self):
1226         """Compute overall Cheesecake index for the package by adding up
1227         specific indexes.
1228         """
1229         # Recursively compute all indices.
1230         max_cheesecake_index = self.index.max_value
1231
1232         # Pass Cheesecake instance to the main Index object.
1233         cheesecake_index = self.index.compute_with(self)
1234         percentage = (cheesecake_index * 100) / max_cheesecake_index
1235
1236         self.log.info("A given package can currently reach a MAXIMUM number of %d points" % max_cheesecake_index)
1237         self.log.info("Starting computation of Cheesecake index for package '%s'" % (self.package))
1238
1239         # Print summary.
1240         if self.quiet:
1241             print "Cheesecake index: %d (%d / %d)" % (percentage,
1242                                                       cheesecake_index,
1243                                                       max_cheesecake_index)
1244         else:
1245             print
1246             print pad_line("=")
1247             print pad_msg("OVERALL CHEESECAKE INDEX (ABSOLUTE)", cheesecake_index)
1248             print "%s  (%d out of a maximum of %d points is %d%%)" % \
1249                   (pad_msg("OVERALL CHEESECAKE INDEX (RELATIVE)", percentage),
1250                    cheesecake_index,
1251                    max_cheesecake_index,
1252                    percentage)
1253
1254         return cheesecake_index
1255
1256 ################################################################################
1257 ## Command line.
1258 ################################################################################
1259
1260 def process_cmdline_args():
1261     """Parse command-line options.
1262     """
1263     parser = OptionParser()
1264     parser.add_option("-n", "--name", dest="name",
1265                       default="", help="package name (will be retrieved via setuptools utilities, if present)")
1266     parser.add_option("-u", "--url", dest="url",
1267                       default="", help="package URL")
1268     parser.add_option("-p", "--path", dest="path",
1269                       default="", help="path of tar.gz/zip package on local file system")
1270     parser.add_option("-s", "--sandbox", dest="sandbox",
1271                       default=None,
1272                       help="directory where package will be unpacked "\
1273                            "(default is to use random directory inside %s)" % tempfile.gettempdir())
1274     parser.add_option("-c", "--config", dest="config",
1275                       default=None,
1276                       help="directory with custom configuration (default=~/.cheesecake)")
1277     parser.add_option("-l", "--logfile", dest="logfile",
1278                       default=None,
1279                       help="file to log all cheesecake messages")
1280     parser.add_option("-v", "--verbose", action="store_true", dest="verbose",
1281                       default=False, help="verbose output (default=False)")
1282     parser.add_option("-q", "--quiet", action="store_true", dest="quiet",
1283                       default=False, help="only print Cheesecake index value (default=False)")
1284
1285     (options, args) = parser.parse_args()
1286     return options
1287
1288 def main():
1289     """Display Cheesecake index for package specified via command-line options.
1290     """
1291     options = process_cmdline_args()
1292     name = options.name
1293     url = options.url
1294     path = options.path
1295     sandbox = options.sandbox
1296     config = options.config
1297     logfile = options.logfile
1298     verbose = options.verbose
1299     quiet = options.quiet
1300
1301     if not name and not url and not path:
1302         print "Error: No package name, URL or path specified (see --help)"
1303         sys.exit(1)
1304
1305     try:
1306         c = Cheesecake(name=name, url=url, path=path, sandbox=sandbox,
1307                        config=config, logfile=logfile, verbose=verbose,
1308                        quiet=quiet)
1309         c.compute_cheesecake_index()
1310         c.cleanup()
1311     except CheesecakeError, e:
1312         print str(e)
1313
1314 if __name__ == "__main__":
1315     main()
Note: See TracBrowser for help on using the browser.