6 import datetime |
6 import datetime |
7 import json |
7 import json |
8 import os |
8 import os |
9 import re |
9 import re |
10 import shutil |
10 import shutil |
|
11 import types |
11 |
12 |
12 import coverage |
13 import coverage |
13 from coverage import env |
|
14 from coverage.backward import iitems, SimpleNamespace, format_local_datetime |
|
15 from coverage.data import add_data_to_hash |
14 from coverage.data import add_data_to_hash |
|
15 from coverage.exceptions import CoverageException |
16 from coverage.files import flat_rootname |
16 from coverage.files import flat_rootname |
17 from coverage.misc import CoverageException, ensure_dir, file_be_gone, Hasher, isolate_module |
17 from coverage.misc import ensure_dir, file_be_gone, Hasher, isolate_module, format_local_datetime |
|
18 from coverage.misc import human_sorted |
18 from coverage.report import get_analysis_to_report |
19 from coverage.report import get_analysis_to_report |
19 from coverage.results import Numbers |
20 from coverage.results import Numbers |
20 from coverage.templite import Templite |
21 from coverage.templite import Templite |
21 |
22 |
22 os = isolate_module(os) |
23 os = isolate_module(os) |
23 |
24 |
24 |
25 |
25 # Static files are looked for in a list of places. |
26 def data_filename(fname): |
26 STATIC_PATH = [ |
27 """Return the path to an "htmlfiles" data file of ours. |
27 # The place Debian puts system Javascript libraries. |
|
28 "/usr/share/javascript", |
|
29 |
|
30 # Our htmlfiles directory. |
|
31 os.path.join(os.path.dirname(__file__), "htmlfiles"), |
|
32 ] |
|
33 |
|
34 |
|
35 def data_filename(fname, pkgdir=""): |
|
36 """Return the path to a data file of ours. |
|
37 |
|
38 The file is searched for on `STATIC_PATH`, and the first place it's found, |
|
39 is returned. |
|
40 |
|
41 Each directory in `STATIC_PATH` is searched as-is, and also, if `pkgdir` |
|
42 is provided, at that sub-directory. |
|
43 |
|
44 """ |
28 """ |
45 tried = [] |
29 static_dir = os.path.join(os.path.dirname(__file__), "htmlfiles") |
46 for static_dir in STATIC_PATH: |
30 static_filename = os.path.join(static_dir, fname) |
47 static_filename = os.path.join(static_dir, fname) |
31 return static_filename |
48 if os.path.exists(static_filename): |
|
49 return static_filename |
|
50 else: |
|
51 tried.append(static_filename) |
|
52 if pkgdir: |
|
53 static_filename = os.path.join(static_dir, pkgdir, fname) |
|
54 if os.path.exists(static_filename): |
|
55 return static_filename |
|
56 else: |
|
57 tried.append(static_filename) |
|
58 raise CoverageException( |
|
59 "Couldn't find static file %r from %r, tried: %r" % (fname, os.getcwd(), tried) |
|
60 ) |
|
61 |
32 |
62 |
33 |
63 def read_data(fname): |
34 def read_data(fname): |
64 """Return the contents of a data file of ours.""" |
35 """Return the contents of a data file of ours.""" |
65 with open(data_filename(fname)) as data_file: |
36 with open(data_filename(fname)) as data_file: |
71 html = re.sub(r"(\A\s+)|(\s+$)", "", html, flags=re.MULTILINE) + "\n" |
42 html = re.sub(r"(\A\s+)|(\s+$)", "", html, flags=re.MULTILINE) + "\n" |
72 with open(fname, "wb") as fout: |
43 with open(fname, "wb") as fout: |
73 fout.write(html.encode('ascii', 'xmlcharrefreplace')) |
44 fout.write(html.encode('ascii', 'xmlcharrefreplace')) |
74 |
45 |
75 |
46 |
76 class HtmlDataGeneration(object): |
47 class HtmlDataGeneration: |
77 """Generate structured data to be turned into HTML reports.""" |
48 """Generate structured data to be turned into HTML reports.""" |
78 |
49 |
79 EMPTY = "(empty)" |
50 EMPTY = "(empty)" |
80 |
51 |
81 def __init__(self, cov): |
52 def __init__(self, cov): |
121 category = 'run' |
92 category = 'run' |
122 |
93 |
123 contexts = contexts_label = None |
94 contexts = contexts_label = None |
124 context_list = None |
95 context_list = None |
125 if category and self.config.show_contexts: |
96 if category and self.config.show_contexts: |
126 contexts = sorted(c or self.EMPTY for c in contexts_by_lineno[lineno]) |
97 contexts = human_sorted(c or self.EMPTY for c in contexts_by_lineno.get(lineno, ())) |
127 if contexts == [self.EMPTY]: |
98 if contexts == [self.EMPTY]: |
128 contexts_label = self.EMPTY |
99 contexts_label = self.EMPTY |
129 else: |
100 else: |
130 contexts_label = "{} ctx".format(len(contexts)) |
101 contexts_label = f"{len(contexts)} ctx" |
131 context_list = contexts |
102 context_list = contexts |
132 |
103 |
133 lines.append(SimpleNamespace( |
104 lines.append(types.SimpleNamespace( |
134 tokens=tokens, |
105 tokens=tokens, |
135 number=lineno, |
106 number=lineno, |
136 category=category, |
107 category=category, |
137 statement=(lineno in analysis.statements), |
108 statement=(lineno in analysis.statements), |
138 contexts=contexts, |
109 contexts=contexts, |
140 context_list=context_list, |
111 context_list=context_list, |
141 short_annotations=short_annotations, |
112 short_annotations=short_annotations, |
142 long_annotations=long_annotations, |
113 long_annotations=long_annotations, |
143 )) |
114 )) |
144 |
115 |
145 file_data = SimpleNamespace( |
116 file_data = types.SimpleNamespace( |
146 relative_filename=fr.relative_filename(), |
117 relative_filename=fr.relative_filename(), |
147 nums=analysis.numbers, |
118 nums=analysis.numbers, |
148 lines=lines, |
119 lines=lines, |
149 ) |
120 ) |
150 |
121 |
151 return file_data |
122 return file_data |
152 |
123 |
153 |
124 |
154 class HtmlReporter(object): |
125 class HtmlReporter: |
155 """HTML reporting.""" |
126 """HTML reporting.""" |
156 |
127 |
157 # These files will be copied from the htmlfiles directory to the output |
128 # These files will be copied from the htmlfiles directory to the output |
158 # directory. |
129 # directory. |
159 STATIC_FILES = [ |
130 STATIC_FILES = [ |
160 ("style.css", ""), |
131 "style.css", |
161 ("jquery.min.js", "jquery"), |
132 "coverage_html.js", |
162 ("jquery.ba-throttle-debounce.min.js", "jquery-throttle-debounce"), |
133 "keybd_closed.png", |
163 ("jquery.hotkeys.js", "jquery-hotkeys"), |
134 "keybd_open.png", |
164 ("jquery.isonscreen.js", "jquery-isonscreen"), |
135 "favicon_32.png", |
165 ("jquery.tablesorter.min.js", "jquery-tablesorter"), |
|
166 ("coverage_html.js", ""), |
|
167 ("keybd_closed.png", ""), |
|
168 ("keybd_open.png", ""), |
|
169 ("favicon_32.png", ""), |
|
170 ] |
136 ] |
171 |
137 |
172 def __init__(self, cov): |
138 def __init__(self, cov): |
173 self.coverage = cov |
139 self.coverage = cov |
174 self.config = self.coverage.config |
140 self.config = self.coverage.config |
177 self.skip_covered = self.config.html_skip_covered |
143 self.skip_covered = self.config.html_skip_covered |
178 if self.skip_covered is None: |
144 if self.skip_covered is None: |
179 self.skip_covered = self.config.skip_covered |
145 self.skip_covered = self.config.skip_covered |
180 self.skip_empty = self.config.html_skip_empty |
146 self.skip_empty = self.config.html_skip_empty |
181 if self.skip_empty is None: |
147 if self.skip_empty is None: |
182 self.skip_empty= self.config.skip_empty |
148 self.skip_empty = self.config.skip_empty |
|
149 self.skipped_covered_count = 0 |
|
150 self.skipped_empty_count = 0 |
183 |
151 |
184 title = self.config.html_title |
152 title = self.config.html_title |
185 if env.PY2: |
|
186 title = title.decode("utf8") |
|
187 |
153 |
188 if self.config.extra_css: |
154 if self.config.extra_css: |
189 self.extra_css = os.path.basename(self.config.extra_css) |
155 self.extra_css = os.path.basename(self.config.extra_css) |
190 else: |
156 else: |
191 self.extra_css = None |
157 self.extra_css = None |
195 |
161 |
196 self.file_summaries = [] |
162 self.file_summaries = [] |
197 self.all_files_nums = [] |
163 self.all_files_nums = [] |
198 self.incr = IncrementalChecker(self.directory) |
164 self.incr = IncrementalChecker(self.directory) |
199 self.datagen = HtmlDataGeneration(self.coverage) |
165 self.datagen = HtmlDataGeneration(self.coverage) |
200 self.totals = Numbers() |
166 self.totals = Numbers(precision=self.config.precision) |
201 |
167 |
202 self.template_globals = { |
168 self.template_globals = { |
203 # Functions available in the templates. |
169 # Functions available in the templates. |
204 'escape': escape, |
170 'escape': escape, |
205 'pair': pair, |
171 'pair': pair, |
253 return self.totals.n_statements and self.totals.pc_covered |
219 return self.totals.n_statements and self.totals.pc_covered |
254 |
220 |
255 def make_local_static_report_files(self): |
221 def make_local_static_report_files(self): |
256 """Make local instances of static files for HTML report.""" |
222 """Make local instances of static files for HTML report.""" |
257 # The files we provide must always be copied. |
223 # The files we provide must always be copied. |
258 for static, pkgdir in self.STATIC_FILES: |
224 for static in self.STATIC_FILES: |
259 shutil.copyfile( |
225 shutil.copyfile(data_filename(static), os.path.join(self.directory, static)) |
260 data_filename(static, pkgdir), |
226 |
261 os.path.join(self.directory, static) |
227 # .gitignore can't be copied from the source tree because it would |
262 ) |
228 # prevent the static files from being checked in. |
|
229 with open(os.path.join(self.directory, ".gitignore"), "w") as fgi: |
|
230 fgi.write("# Created by coverage.py\n*\n") |
263 |
231 |
264 # The user may have extra CSS they want copied. |
232 # The user may have extra CSS they want copied. |
265 if self.extra_css: |
233 if self.extra_css: |
266 shutil.copyfile( |
234 shutil.copyfile(self.config.extra_css, os.path.join(self.directory, self.extra_css)) |
267 self.config.extra_css, |
|
268 os.path.join(self.directory, self.extra_css) |
|
269 ) |
|
270 |
235 |
271 def html_file(self, fr, analysis): |
236 def html_file(self, fr, analysis): |
272 """Generate an HTML file for one source file.""" |
237 """Generate an HTML file for one source file.""" |
273 rootname = flat_rootname(fr.relative_filename()) |
238 rootname = flat_rootname(fr.relative_filename()) |
274 html_filename = rootname + ".html" |
239 html_filename = rootname + ".html" |
284 no_missing_lines = (nums.n_missing == 0) |
249 no_missing_lines = (nums.n_missing == 0) |
285 no_missing_branches = (nums.n_partial_branches == 0) |
250 no_missing_branches = (nums.n_partial_branches == 0) |
286 if no_missing_lines and no_missing_branches: |
251 if no_missing_lines and no_missing_branches: |
287 # If there's an existing file, remove it. |
252 # If there's an existing file, remove it. |
288 file_be_gone(html_path) |
253 file_be_gone(html_path) |
|
254 self.skipped_covered_count += 1 |
289 return |
255 return |
290 |
256 |
291 if self.skip_empty: |
257 if self.skip_empty: |
292 # Don't report on empty files. |
258 # Don't report on empty files. |
293 if nums.n_statements == 0: |
259 if nums.n_statements == 0: |
294 file_be_gone(html_path) |
260 file_be_gone(html_path) |
|
261 self.skipped_empty_count += 1 |
295 return |
262 return |
296 |
263 |
297 # Find out if the file on disk is already correct. |
264 # Find out if the file on disk is already correct. |
298 if self.incr.can_skip_file(self.data, fr, rootname): |
265 if self.incr.can_skip_file(self.data, fr, rootname): |
299 self.file_summaries.append(self.incr.index_info(rootname)) |
266 self.file_summaries.append(self.incr.index_info(rootname)) |
308 if tok_type == "ws": |
275 if tok_type == "ws": |
309 html.append(escape(tok_text)) |
276 html.append(escape(tok_text)) |
310 else: |
277 else: |
311 tok_html = escape(tok_text) or ' ' |
278 tok_html = escape(tok_text) or ' ' |
312 html.append( |
279 html.append( |
313 u'<span class="{}">{}</span>'.format(tok_type, tok_html) |
280 f'<span class="{tok_type}">{tok_html}</span>' |
314 ) |
281 ) |
315 ldata.html = ''.join(html) |
282 ldata.html = ''.join(html) |
316 |
283 |
317 if ldata.short_annotations: |
284 if ldata.short_annotations: |
318 # 202F is NARROW NO-BREAK SPACE. |
285 # 202F is NARROW NO-BREAK SPACE. |
319 # 219B is RIGHTWARDS ARROW WITH STROKE. |
286 # 219B is RIGHTWARDS ARROW WITH STROKE. |
320 ldata.annotate = u", ".join( |
287 ldata.annotate = ", ".join( |
321 u"{} ↛ {}".format(ldata.number, d) |
288 f"{ldata.number} ↛ {d}" |
322 for d in ldata.short_annotations |
289 for d in ldata.short_annotations |
323 ) |
290 ) |
324 else: |
291 else: |
325 ldata.annotate = None |
292 ldata.annotate = None |
326 |
293 |
327 if ldata.long_annotations: |
294 if ldata.long_annotations: |
328 longs = ldata.long_annotations |
295 longs = ldata.long_annotations |
329 if len(longs) == 1: |
296 if len(longs) == 1: |
330 ldata.annotate_long = longs[0] |
297 ldata.annotate_long = longs[0] |
331 else: |
298 else: |
332 ldata.annotate_long = u"{:d} missed branches: {}".format( |
299 ldata.annotate_long = "{:d} missed branches: {}".format( |
333 len(longs), |
300 len(longs), |
334 u", ".join( |
301 ", ".join( |
335 u"{:d}) {}".format(num, ann_long) |
302 f"{num:d}) {ann_long}" |
336 for num, ann_long in enumerate(longs, start=1) |
303 for num, ann_long in enumerate(longs, start=1) |
337 ), |
304 ), |
338 ) |
305 ) |
339 else: |
306 else: |
340 ldata.annotate_long = None |
307 ldata.annotate_long = None |
358 |
325 |
359 def index_file(self): |
326 def index_file(self): |
360 """Write the index.html file for this report.""" |
327 """Write the index.html file for this report.""" |
361 index_tmpl = Templite(read_data("index.html"), self.template_globals) |
328 index_tmpl = Templite(read_data("index.html"), self.template_globals) |
362 |
329 |
|
330 skipped_covered_msg = skipped_empty_msg = "" |
|
331 if self.skipped_covered_count: |
|
332 msg = "{} {} skipped due to complete coverage." |
|
333 skipped_covered_msg = msg.format( |
|
334 self.skipped_covered_count, |
|
335 "file" if self.skipped_covered_count == 1 else "files", |
|
336 ) |
|
337 if self.skipped_empty_count: |
|
338 msg = "{} empty {} skipped." |
|
339 skipped_empty_msg = msg.format( |
|
340 self.skipped_empty_count, |
|
341 "file" if self.skipped_empty_count == 1 else "files", |
|
342 ) |
|
343 |
363 html = index_tmpl.render({ |
344 html = index_tmpl.render({ |
364 'files': self.file_summaries, |
345 'files': self.file_summaries, |
365 'totals': self.totals, |
346 'totals': self.totals, |
|
347 'skipped_covered_msg': skipped_covered_msg, |
|
348 'skipped_empty_msg': skipped_empty_msg, |
366 }) |
349 }) |
367 |
350 |
368 write_html(os.path.join(self.directory, "index.html"), html) |
351 index_file = os.path.join(self.directory, "index.html") |
|
352 write_html(index_file, html) |
|
353 self.coverage._message(f"Wrote HTML report to {index_file}") |
369 |
354 |
370 # Write the latest hashes for next time. |
355 # Write the latest hashes for next time. |
371 self.incr.write() |
356 self.incr.write() |
372 |
357 |
373 |
358 |
374 class IncrementalChecker(object): |
359 class IncrementalChecker: |
375 """Logic and data to support incremental reporting.""" |
360 """Logic and data to support incremental reporting.""" |
376 |
361 |
377 STATUS_FILE = "status.json" |
362 STATUS_FILE = "status.json" |
378 STATUS_FORMAT = 2 |
363 STATUS_FORMAT = 2 |
379 |
364 |
419 usable = False |
404 usable = False |
420 try: |
405 try: |
421 status_file = os.path.join(self.directory, self.STATUS_FILE) |
406 status_file = os.path.join(self.directory, self.STATUS_FILE) |
422 with open(status_file) as fstatus: |
407 with open(status_file) as fstatus: |
423 status = json.load(fstatus) |
408 status = json.load(fstatus) |
424 except (IOError, ValueError): |
409 except (OSError, ValueError): |
425 usable = False |
410 usable = False |
426 else: |
411 else: |
427 usable = True |
412 usable = True |
428 if status['format'] != self.STATUS_FORMAT: |
413 if status['format'] != self.STATUS_FORMAT: |
429 usable = False |
414 usable = False |
430 elif status['version'] != coverage.__version__: |
415 elif status['version'] != coverage.__version__: |
431 usable = False |
416 usable = False |
432 |
417 |
433 if usable: |
418 if usable: |
434 self.files = {} |
419 self.files = {} |
435 for filename, fileinfo in iitems(status['files']): |
420 for filename, fileinfo in status['files'].items(): |
436 fileinfo['index']['nums'] = Numbers(*fileinfo['index']['nums']) |
421 fileinfo['index']['nums'] = Numbers(*fileinfo['index']['nums']) |
437 self.files[filename] = fileinfo |
422 self.files[filename] = fileinfo |
438 self.globals = status['globals'] |
423 self.globals = status['globals'] |
439 else: |
424 else: |
440 self.reset() |
425 self.reset() |
441 |
426 |
442 def write(self): |
427 def write(self): |
443 """Write the current status.""" |
428 """Write the current status.""" |
444 status_file = os.path.join(self.directory, self.STATUS_FILE) |
429 status_file = os.path.join(self.directory, self.STATUS_FILE) |
445 files = {} |
430 files = {} |
446 for filename, fileinfo in iitems(self.files): |
431 for filename, fileinfo in self.files.items(): |
447 fileinfo['index']['nums'] = fileinfo['index']['nums'].init_args() |
432 fileinfo['index']['nums'] = fileinfo['index']['nums'].init_args() |
448 files[filename] = fileinfo |
433 files[filename] = fileinfo |
449 |
434 |
450 status = { |
435 status = { |
451 'format': self.STATUS_FORMAT, |
436 'format': self.STATUS_FORMAT, |