root/branches/mk/cheesecake/cheesecake_index.py

Revision 60, 39.8 kB (checked in by mk, 7 years ago)

Check that Cheesecake is properly cleaning up (closes ticket #28).

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