root/branches/mk/cheesecake/cheesecake_index.py

Revision 73, 40.8 kB (checked in by mk, 7 years ago)

PyPI index: Penalize for PyPI distance > 1.

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