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