root/branches/mk/cheesecake/cheesecake_index.py

Revision 57, 39.9 kB (checked in by mk, 7 years ago)

Unified Cheesecake score formatting.

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