|
1 # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 |
|
2 # For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt |
|
3 |
|
4 """Python source expertise for coverage.py""" |
|
5 |
|
6 import os.path |
|
7 import types |
|
8 import zipimport |
|
9 |
|
10 from coverage import env, files |
|
11 from coverage.misc import contract, expensive, isolate_module, join_regex |
|
12 from coverage.misc import CoverageException, NoSource |
|
13 from coverage.parser import PythonParser |
|
14 from coverage.phystokens import source_token_lines, source_encoding |
|
15 from coverage.plugin import FileReporter |
|
16 |
|
17 os = isolate_module(os) |
|
18 |
|
19 |
|
20 @contract(returns='bytes') |
|
21 def read_python_source(filename): |
|
22 """Read the Python source text from `filename`. |
|
23 |
|
24 Returns bytes. |
|
25 |
|
26 """ |
|
27 with open(filename, "rb") as f: |
|
28 source = f.read() |
|
29 |
|
30 if env.IRONPYTHON: |
|
31 # IronPython reads Unicode strings even for "rb" files. |
|
32 source = bytes(source) |
|
33 |
|
34 return source.replace(b"\r\n", b"\n").replace(b"\r", b"\n") |
|
35 |
|
36 |
|
37 @contract(returns='unicode') |
|
38 def get_python_source(filename): |
|
39 """Return the source code, as unicode.""" |
|
40 base, ext = os.path.splitext(filename) |
|
41 if ext == ".py" and env.WINDOWS: |
|
42 exts = [".py", ".pyw"] |
|
43 else: |
|
44 exts = [ext] |
|
45 |
|
46 for ext in exts: |
|
47 try_filename = base + ext |
|
48 if os.path.exists(try_filename): |
|
49 # A regular text file: open it. |
|
50 source = read_python_source(try_filename) |
|
51 break |
|
52 |
|
53 # Maybe it's in a zip file? |
|
54 source = get_zip_bytes(try_filename) |
|
55 if source is not None: |
|
56 break |
|
57 else: |
|
58 # Couldn't find source. |
|
59 exc_msg = "No source for code: '%s'.\n" % (filename,) |
|
60 exc_msg += "Aborting report output, consider using -i." |
|
61 raise NoSource(exc_msg) |
|
62 |
|
63 # Replace \f because of http://bugs.python.org/issue19035 |
|
64 source = source.replace(b'\f', b' ') |
|
65 source = source.decode(source_encoding(source), "replace") |
|
66 |
|
67 # Python code should always end with a line with a newline. |
|
68 if source and source[-1] != '\n': |
|
69 source += '\n' |
|
70 |
|
71 return source |
|
72 |
|
73 |
|
74 @contract(returns='bytes|None') |
|
75 def get_zip_bytes(filename): |
|
76 """Get data from `filename` if it is a zip file path. |
|
77 |
|
78 Returns the bytestring data read from the zip file, or None if no zip file |
|
79 could be found or `filename` isn't in it. The data returned will be |
|
80 an empty string if the file is empty. |
|
81 |
|
82 """ |
|
83 markers = ['.zip'+os.sep, '.egg'+os.sep, '.pex'+os.sep] |
|
84 for marker in markers: |
|
85 if marker in filename: |
|
86 parts = filename.split(marker) |
|
87 try: |
|
88 zi = zipimport.zipimporter(parts[0]+marker[:-1]) |
|
89 except zipimport.ZipImportError: |
|
90 continue |
|
91 try: |
|
92 data = zi.get_data(parts[1]) |
|
93 except IOError: |
|
94 continue |
|
95 return data |
|
96 return None |
|
97 |
|
98 |
|
99 def source_for_file(filename): |
|
100 """Return the source filename for `filename`. |
|
101 |
|
102 Given a file name being traced, return the best guess as to the source |
|
103 file to attribute it to. |
|
104 |
|
105 """ |
|
106 if filename.endswith(".py"): |
|
107 # .py files are themselves source files. |
|
108 return filename |
|
109 |
|
110 elif filename.endswith((".pyc", ".pyo")): |
|
111 # Bytecode files probably have source files near them. |
|
112 py_filename = filename[:-1] |
|
113 if os.path.exists(py_filename): |
|
114 # Found a .py file, use that. |
|
115 return py_filename |
|
116 if env.WINDOWS: |
|
117 # On Windows, it could be a .pyw file. |
|
118 pyw_filename = py_filename + "w" |
|
119 if os.path.exists(pyw_filename): |
|
120 return pyw_filename |
|
121 # Didn't find source, but it's probably the .py file we want. |
|
122 return py_filename |
|
123 |
|
124 elif filename.endswith("$py.class"): |
|
125 # Jython is easy to guess. |
|
126 return filename[:-9] + ".py" |
|
127 |
|
128 # No idea, just use the file name as-is. |
|
129 return filename |
|
130 |
|
131 |
|
132 def source_for_morf(morf): |
|
133 """Get the source filename for the module-or-file `morf`.""" |
|
134 if hasattr(morf, '__file__') and morf.__file__: |
|
135 filename = morf.__file__ |
|
136 elif isinstance(morf, types.ModuleType): |
|
137 # A module should have had .__file__, otherwise we can't use it. |
|
138 # This could be a PEP-420 namespace package. |
|
139 raise CoverageException("Module {} has no file".format(morf)) |
|
140 else: |
|
141 filename = morf |
|
142 |
|
143 filename = source_for_file(files.unicode_filename(filename)) |
|
144 return filename |
|
145 |
|
146 |
|
147 class PythonFileReporter(FileReporter): |
|
148 """Report support for a Python file.""" |
|
149 |
|
150 def __init__(self, morf, coverage=None): |
|
151 self.coverage = coverage |
|
152 |
|
153 filename = source_for_morf(morf) |
|
154 |
|
155 super(PythonFileReporter, self).__init__(files.canonical_filename(filename)) |
|
156 |
|
157 if hasattr(morf, '__name__'): |
|
158 name = morf.__name__.replace(".", os.sep) |
|
159 if os.path.basename(filename).startswith('__init__.'): |
|
160 name += os.sep + "__init__" |
|
161 name += ".py" |
|
162 name = files.unicode_filename(name) |
|
163 else: |
|
164 name = files.relative_filename(filename) |
|
165 self.relname = name |
|
166 |
|
167 self._source = None |
|
168 self._parser = None |
|
169 self._excluded = None |
|
170 |
|
171 def __repr__(self): |
|
172 return "<PythonFileReporter {!r}>".format(self.filename) |
|
173 |
|
174 @contract(returns='unicode') |
|
175 def relative_filename(self): |
|
176 return self.relname |
|
177 |
|
178 @property |
|
179 def parser(self): |
|
180 """Lazily create a :class:`PythonParser`.""" |
|
181 if self._parser is None: |
|
182 self._parser = PythonParser( |
|
183 filename=self.filename, |
|
184 exclude=self.coverage._exclude_regex('exclude'), |
|
185 ) |
|
186 self._parser.parse_source() |
|
187 return self._parser |
|
188 |
|
189 def lines(self): |
|
190 """Return the line numbers of statements in the file.""" |
|
191 return self.parser.statements |
|
192 |
|
193 def excluded_lines(self): |
|
194 """Return the line numbers of statements in the file.""" |
|
195 return self.parser.excluded |
|
196 |
|
197 def translate_lines(self, lines): |
|
198 return self.parser.translate_lines(lines) |
|
199 |
|
200 def translate_arcs(self, arcs): |
|
201 return self.parser.translate_arcs(arcs) |
|
202 |
|
203 @expensive |
|
204 def no_branch_lines(self): |
|
205 no_branch = self.parser.lines_matching( |
|
206 join_regex(self.coverage.config.partial_list), |
|
207 join_regex(self.coverage.config.partial_always_list) |
|
208 ) |
|
209 return no_branch |
|
210 |
|
211 @expensive |
|
212 def arcs(self): |
|
213 return self.parser.arcs() |
|
214 |
|
215 @expensive |
|
216 def exit_counts(self): |
|
217 return self.parser.exit_counts() |
|
218 |
|
219 def missing_arc_description(self, start, end, executed_arcs=None): |
|
220 return self.parser.missing_arc_description(start, end, executed_arcs) |
|
221 |
|
222 @contract(returns='unicode') |
|
223 def source(self): |
|
224 if self._source is None: |
|
225 self._source = get_python_source(self.filename) |
|
226 return self._source |
|
227 |
|
228 def should_be_python(self): |
|
229 """Does it seem like this file should contain Python? |
|
230 |
|
231 This is used to decide if a file reported as part of the execution of |
|
232 a program was really likely to have contained Python in the first |
|
233 place. |
|
234 |
|
235 """ |
|
236 # Get the file extension. |
|
237 _, ext = os.path.splitext(self.filename) |
|
238 |
|
239 # Anything named *.py* should be Python. |
|
240 if ext.startswith('.py'): |
|
241 return True |
|
242 # A file with no extension should be Python. |
|
243 if not ext: |
|
244 return True |
|
245 # Everything else is probably not Python. |
|
246 return False |
|
247 |
|
248 def source_token_lines(self): |
|
249 return source_token_lines(self.source()) |