1# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 2# For details: https://bitbucket.org/ned/coveragepy/src/default/NOTICE.txt 3 4"""Python source expertise for coverage.py""" 5 6import os.path 7import zipimport 8 9from coverage import env, files 10from coverage.misc import contract, expensive, NoSource, join_regex, isolate_module 11from coverage.parser import PythonParser 12from coverage.phystokens import source_token_lines, source_encoding 13from coverage.plugin import FileReporter 14 15os = isolate_module(os) 16 17 18@contract(returns='bytes') 19def read_python_source(filename): 20 """Read the Python source text from `filename`. 21 22 Returns bytes. 23 24 """ 25 with open(filename, "rb") as f: 26 return f.read().replace(b"\r\n", b"\n").replace(b"\r", b"\n") 27 28 29@contract(returns='unicode') 30def get_python_source(filename): 31 """Return the source code, as unicode.""" 32 base, ext = os.path.splitext(filename) 33 if ext == ".py" and env.WINDOWS: 34 exts = [".py", ".pyw"] 35 else: 36 exts = [ext] 37 38 for ext in exts: 39 try_filename = base + ext 40 if os.path.exists(try_filename): 41 # A regular text file: open it. 42 source = read_python_source(try_filename) 43 break 44 45 # Maybe it's in a zip file? 46 source = get_zip_bytes(try_filename) 47 if source is not None: 48 break 49 else: 50 # Couldn't find source. 51 raise NoSource("No source for code: '%s'." % filename) 52 53 source = source.decode(source_encoding(source), "replace") 54 55 # Python code should always end with a line with a newline. 56 if source and source[-1] != '\n': 57 source += '\n' 58 59 return source 60 61 62@contract(returns='bytes|None') 63def get_zip_bytes(filename): 64 """Get data from `filename` if it is a zip file path. 65 66 Returns the bytestring data read from the zip file, or None if no zip file 67 could be found or `filename` isn't in it. The data returned will be 68 an empty string if the file is empty. 69 70 """ 71 markers = ['.zip'+os.sep, '.egg'+os.sep] 72 for marker in markers: 73 if marker in filename: 74 parts = filename.split(marker) 75 try: 76 zi = zipimport.zipimporter(parts[0]+marker[:-1]) 77 except zipimport.ZipImportError: 78 continue 79 try: 80 data = zi.get_data(parts[1]) 81 except IOError: 82 continue 83 return data 84 return None 85 86 87class PythonFileReporter(FileReporter): 88 """Report support for a Python file.""" 89 90 def __init__(self, morf, coverage=None): 91 self.coverage = coverage 92 93 if hasattr(morf, '__file__'): 94 filename = morf.__file__ 95 else: 96 filename = morf 97 98 filename = files.unicode_filename(filename) 99 100 # .pyc files should always refer to a .py instead. 101 if filename.endswith(('.pyc', '.pyo')): 102 filename = filename[:-1] 103 elif filename.endswith('$py.class'): # Jython 104 filename = filename[:-9] + ".py" 105 106 super(PythonFileReporter, self).__init__(files.canonical_filename(filename)) 107 108 if hasattr(morf, '__name__'): 109 name = morf.__name__ 110 name = name.replace(".", os.sep) + ".py" 111 name = files.unicode_filename(name) 112 else: 113 name = files.relative_filename(filename) 114 self.relname = name 115 116 self._source = None 117 self._parser = None 118 self._statements = None 119 self._excluded = None 120 121 @contract(returns='unicode') 122 def relative_filename(self): 123 return self.relname 124 125 @property 126 def parser(self): 127 """Lazily create a :class:`PythonParser`.""" 128 if self._parser is None: 129 self._parser = PythonParser( 130 filename=self.filename, 131 exclude=self.coverage._exclude_regex('exclude'), 132 ) 133 return self._parser 134 135 @expensive 136 def lines(self): 137 """Return the line numbers of statements in the file.""" 138 if self._statements is None: 139 self._statements, self._excluded = self.parser.parse_source() 140 return self._statements 141 142 @expensive 143 def excluded_lines(self): 144 """Return the line numbers of statements in the file.""" 145 if self._excluded is None: 146 self._statements, self._excluded = self.parser.parse_source() 147 return self._excluded 148 149 def translate_lines(self, lines): 150 return self.parser.translate_lines(lines) 151 152 def translate_arcs(self, arcs): 153 return self.parser.translate_arcs(arcs) 154 155 @expensive 156 def no_branch_lines(self): 157 no_branch = self.parser.lines_matching( 158 join_regex(self.coverage.config.partial_list), 159 join_regex(self.coverage.config.partial_always_list) 160 ) 161 return no_branch 162 163 @expensive 164 def arcs(self): 165 return self.parser.arcs() 166 167 @expensive 168 def exit_counts(self): 169 return self.parser.exit_counts() 170 171 @contract(returns='unicode') 172 def source(self): 173 if self._source is None: 174 self._source = get_python_source(self.filename) 175 return self._source 176 177 def should_be_python(self): 178 """Does it seem like this file should contain Python? 179 180 This is used to decide if a file reported as part of the execution of 181 a program was really likely to have contained Python in the first 182 place. 183 184 """ 185 # Get the file extension. 186 _, ext = os.path.splitext(self.filename) 187 188 # Anything named *.py* should be Python. 189 if ext.startswith('.py'): 190 return True 191 # A file with no extension should be Python. 192 if not ext: 193 return True 194 # Everything else is probably not Python. 195 return False 196 197 def source_token_lines(self): 198 return source_token_lines(self.source()) 199