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