| """Classes for producing HTML reports about imprecision.""" |
| |
| from __future__ import annotations |
| |
| import collections |
| import itertools |
| import json |
| import os |
| import shutil |
| import sys |
| import time |
| import tokenize |
| from abc import ABCMeta, abstractmethod |
| from operator import attrgetter |
| from typing import Any, Callable, Dict, Final, Iterator, Tuple |
| from typing_extensions import TypeAlias as _TypeAlias |
| from urllib.request import pathname2url |
| |
| from mypy import stats |
| from mypy.defaults import REPORTER_NAMES |
| from mypy.nodes import Expression, FuncDef, MypyFile |
| from mypy.options import Options |
| from mypy.traverser import TraverserVisitor |
| from mypy.types import Type, TypeOfAny |
| from mypy.version import __version__ |
| |
| try: |
| from lxml import etree # type: ignore[import] |
| |
| LXML_INSTALLED = True |
| except ImportError: |
| LXML_INSTALLED = False |
| |
| type_of_any_name_map: Final[collections.OrderedDict[int, str]] = collections.OrderedDict( |
| [ |
| (TypeOfAny.unannotated, "Unannotated"), |
| (TypeOfAny.explicit, "Explicit"), |
| (TypeOfAny.from_unimported_type, "Unimported"), |
| (TypeOfAny.from_omitted_generics, "Omitted Generics"), |
| (TypeOfAny.from_error, "Error"), |
| (TypeOfAny.special_form, "Special Form"), |
| (TypeOfAny.implementation_artifact, "Implementation Artifact"), |
| ] |
| ) |
| |
| ReporterClasses: _TypeAlias = Dict[ |
| str, Tuple[Callable[["Reports", str], "AbstractReporter"], bool] |
| ] |
| |
| reporter_classes: Final[ReporterClasses] = {} |
| |
| |
| class Reports: |
| def __init__(self, data_dir: str, report_dirs: dict[str, str]) -> None: |
| self.data_dir = data_dir |
| self.reporters: list[AbstractReporter] = [] |
| self.named_reporters: dict[str, AbstractReporter] = {} |
| |
| for report_type, report_dir in sorted(report_dirs.items()): |
| self.add_report(report_type, report_dir) |
| |
| def add_report(self, report_type: str, report_dir: str) -> AbstractReporter: |
| try: |
| return self.named_reporters[report_type] |
| except KeyError: |
| pass |
| reporter_cls, needs_lxml = reporter_classes[report_type] |
| if needs_lxml and not LXML_INSTALLED: |
| print( |
| ( |
| "You must install the lxml package before you can run mypy" |
| " with `--{}-report`.\n" |
| "You can do this with `python3 -m pip install lxml`." |
| ).format(report_type), |
| file=sys.stderr, |
| ) |
| raise ImportError |
| reporter = reporter_cls(self, report_dir) |
| self.reporters.append(reporter) |
| self.named_reporters[report_type] = reporter |
| return reporter |
| |
| def file( |
| self, |
| tree: MypyFile, |
| modules: dict[str, MypyFile], |
| type_map: dict[Expression, Type], |
| options: Options, |
| ) -> None: |
| for reporter in self.reporters: |
| reporter.on_file(tree, modules, type_map, options) |
| |
| def finish(self) -> None: |
| for reporter in self.reporters: |
| reporter.on_finish() |
| |
| |
| class AbstractReporter(metaclass=ABCMeta): |
| def __init__(self, reports: Reports, output_dir: str) -> None: |
| self.output_dir = output_dir |
| if output_dir != "<memory>": |
| stats.ensure_dir_exists(output_dir) |
| |
| @abstractmethod |
| def on_file( |
| self, |
| tree: MypyFile, |
| modules: dict[str, MypyFile], |
| type_map: dict[Expression, Type], |
| options: Options, |
| ) -> None: |
| pass |
| |
| @abstractmethod |
| def on_finish(self) -> None: |
| pass |
| |
| |
| def register_reporter( |
| report_name: str, |
| reporter: Callable[[Reports, str], AbstractReporter], |
| needs_lxml: bool = False, |
| ) -> None: |
| reporter_classes[report_name] = (reporter, needs_lxml) |
| |
| |
| def alias_reporter(source_reporter: str, target_reporter: str) -> None: |
| reporter_classes[target_reporter] = reporter_classes[source_reporter] |
| |
| |
| def should_skip_path(path: str) -> bool: |
| if stats.is_special_module(path): |
| return True |
| if path.startswith(".."): |
| return True |
| if "stubs" in path.split("/") or "stubs" in path.split(os.sep): |
| return True |
| return False |
| |
| |
| def iterate_python_lines(path: str) -> Iterator[tuple[int, str]]: |
| """Return an iterator over (line number, line text) from a Python file.""" |
| try: |
| with tokenize.open(path) as input_file: |
| yield from enumerate(input_file, 1) |
| except IsADirectoryError: |
| # can happen with namespace packages |
| pass |
| |
| |
| class FuncCounterVisitor(TraverserVisitor): |
| def __init__(self) -> None: |
| super().__init__() |
| self.counts = [0, 0] |
| |
| def visit_func_def(self, defn: FuncDef) -> None: |
| self.counts[defn.type is not None] += 1 |
| |
| |
| class LineCountReporter(AbstractReporter): |
| def __init__(self, reports: Reports, output_dir: str) -> None: |
| super().__init__(reports, output_dir) |
| self.counts: dict[str, tuple[int, int, int, int]] = {} |
| |
| def on_file( |
| self, |
| tree: MypyFile, |
| modules: dict[str, MypyFile], |
| type_map: dict[Expression, Type], |
| options: Options, |
| ) -> None: |
| # Count physical lines. This assumes the file's encoding is a |
| # superset of ASCII (or at least uses \n in its line endings). |
| with open(tree.path, "rb") as f: |
| physical_lines = len(f.readlines()) |
| |
| func_counter = FuncCounterVisitor() |
| tree.accept(func_counter) |
| unannotated_funcs, annotated_funcs = func_counter.counts |
| total_funcs = annotated_funcs + unannotated_funcs |
| |
| # Don't count lines or functions as annotated if they have their errors ignored. |
| if options.ignore_errors: |
| annotated_funcs = 0 |
| |
| imputed_annotated_lines = ( |
| physical_lines * annotated_funcs // total_funcs if total_funcs else physical_lines |
| ) |
| |
| self.counts[tree._fullname] = ( |
| imputed_annotated_lines, |
| physical_lines, |
| annotated_funcs, |
| total_funcs, |
| ) |
| |
| def on_finish(self) -> None: |
| counts: list[tuple[tuple[int, int, int, int], str]] = sorted( |
| ((c, p) for p, c in self.counts.items()), reverse=True |
| ) |
| total_counts = tuple(sum(c[i] for c, p in counts) for i in range(4)) |
| with open(os.path.join(self.output_dir, "linecount.txt"), "w") as f: |
| f.write("{:7} {:7} {:6} {:6} total\n".format(*total_counts)) |
| for c, p in counts: |
| f.write(f"{c[0]:7} {c[1]:7} {c[2]:6} {c[3]:6} {p}\n") |
| |
| |
| register_reporter("linecount", LineCountReporter) |
| |
| |
| class AnyExpressionsReporter(AbstractReporter): |
| """Report frequencies of different kinds of Any types.""" |
| |
| def __init__(self, reports: Reports, output_dir: str) -> None: |
| super().__init__(reports, output_dir) |
| self.counts: dict[str, tuple[int, int]] = {} |
| self.any_types_counter: dict[str, collections.Counter[int]] = {} |
| |
| def on_file( |
| self, |
| tree: MypyFile, |
| modules: dict[str, MypyFile], |
| type_map: dict[Expression, Type], |
| options: Options, |
| ) -> None: |
| visitor = stats.StatisticsVisitor( |
| inferred=True, |
| filename=tree.fullname, |
| modules=modules, |
| typemap=type_map, |
| all_nodes=True, |
| visit_untyped_defs=False, |
| ) |
| tree.accept(visitor) |
| self.any_types_counter[tree.fullname] = visitor.type_of_any_counter |
| num_unanalyzed_lines = list(visitor.line_map.values()).count(stats.TYPE_UNANALYZED) |
| # count each line of dead code as one expression of type "Any" |
| num_any = visitor.num_any_exprs + num_unanalyzed_lines |
| num_total = visitor.num_imprecise_exprs + visitor.num_precise_exprs + num_any |
| if num_total > 0: |
| self.counts[tree.fullname] = (num_any, num_total) |
| |
| def on_finish(self) -> None: |
| self._report_any_exprs() |
| self._report_types_of_anys() |
| |
| def _write_out_report( |
| self, filename: str, header: list[str], rows: list[list[str]], footer: list[str] |
| ) -> None: |
| row_len = len(header) |
| assert all(len(row) == row_len for row in rows + [header, footer]) |
| min_column_distance = 3 # minimum distance between numbers in two columns |
| widths = [-1] * row_len |
| for row in rows + [header, footer]: |
| for i, value in enumerate(row): |
| widths[i] = max(widths[i], len(value)) |
| for i, w in enumerate(widths): |
| # Do not add min_column_distance to the first column. |
| if i > 0: |
| widths[i] = w + min_column_distance |
| with open(os.path.join(self.output_dir, filename), "w") as f: |
| header_str = ("{:>{}}" * len(widths)).format(*itertools.chain(*zip(header, widths))) |
| separator = "-" * len(header_str) |
| f.write(header_str + "\n") |
| f.write(separator + "\n") |
| for row_values in rows: |
| r = ("{:>{}}" * len(widths)).format(*itertools.chain(*zip(row_values, widths))) |
| f.write(r + "\n") |
| f.write(separator + "\n") |
| footer_str = ("{:>{}}" * len(widths)).format(*itertools.chain(*zip(footer, widths))) |
| f.write(footer_str + "\n") |
| |
| def _report_any_exprs(self) -> None: |
| total_any = sum(num_any for num_any, _ in self.counts.values()) |
| total_expr = sum(total for _, total in self.counts.values()) |
| total_coverage = 100.0 |
| if total_expr > 0: |
| total_coverage = (float(total_expr - total_any) / float(total_expr)) * 100 |
| |
| column_names = ["Name", "Anys", "Exprs", "Coverage"] |
| rows: list[list[str]] = [] |
| for filename in sorted(self.counts): |
| (num_any, num_total) = self.counts[filename] |
| coverage = (float(num_total - num_any) / float(num_total)) * 100 |
| coverage_str = f"{coverage:.2f}%" |
| rows.append([filename, str(num_any), str(num_total), coverage_str]) |
| rows.sort(key=lambda x: x[0]) |
| total_row = ["Total", str(total_any), str(total_expr), f"{total_coverage:.2f}%"] |
| self._write_out_report("any-exprs.txt", column_names, rows, total_row) |
| |
| def _report_types_of_anys(self) -> None: |
| total_counter: collections.Counter[int] = collections.Counter() |
| for counter in self.any_types_counter.values(): |
| for any_type, value in counter.items(): |
| total_counter[any_type] += value |
| file_column_name = "Name" |
| total_row_name = "Total" |
| column_names = [file_column_name] + list(type_of_any_name_map.values()) |
| rows: list[list[str]] = [] |
| for filename, counter in self.any_types_counter.items(): |
| rows.append([filename] + [str(counter[typ]) for typ in type_of_any_name_map]) |
| rows.sort(key=lambda x: x[0]) |
| total_row = [total_row_name] + [str(total_counter[typ]) for typ in type_of_any_name_map] |
| self._write_out_report("types-of-anys.txt", column_names, rows, total_row) |
| |
| |
| register_reporter("any-exprs", AnyExpressionsReporter) |
| |
| |
| class LineCoverageVisitor(TraverserVisitor): |
| def __init__(self, source: list[str]) -> None: |
| self.source = source |
| |
| # For each line of source, we maintain a pair of |
| # * the indentation level of the surrounding function |
| # (-1 if not inside a function), and |
| # * whether the surrounding function is typed. |
| # Initially, everything is covered at indentation level -1. |
| self.lines_covered = [(-1, True) for l in source] |
| |
| # The Python AST has position information for the starts of |
| # elements, but not for their ends. Fortunately the |
| # indentation-based syntax makes it pretty easy to find where a |
| # block ends without doing any real parsing. |
| |
| # TODO: Handle line continuations (explicit and implicit) and |
| # multi-line string literals. (But at least line continuations |
| # are normally more indented than their surrounding block anyways, |
| # by PEP 8.) |
| |
| def indentation_level(self, line_number: int) -> int | None: |
| """Return the indentation of a line of the source (specified by |
| zero-indexed line number). Returns None for blank lines or comments.""" |
| line = self.source[line_number] |
| indent = 0 |
| for char in list(line): |
| if char == " ": |
| indent += 1 |
| elif char == "\t": |
| indent = 8 * ((indent + 8) // 8) |
| elif char == "#": |
| # Line is a comment; ignore it |
| return None |
| elif char == "\n": |
| # Line is entirely whitespace; ignore it |
| return None |
| # TODO line continuation (\) |
| else: |
| # Found a non-whitespace character |
| return indent |
| # Line is entirely whitespace, and at end of file |
| # with no trailing newline; ignore it |
| return None |
| |
| def visit_func_def(self, defn: FuncDef) -> None: |
| start_line = defn.line - 1 |
| start_indent = None |
| # When a function is decorated, sometimes the start line will point to |
| # whitespace or comments between the decorator and the function, so |
| # we have to look for the start. |
| while start_line < len(self.source): |
| start_indent = self.indentation_level(start_line) |
| if start_indent is not None: |
| break |
| start_line += 1 |
| # If we can't find the function give up and don't annotate anything. |
| # Our line numbers are not reliable enough to be asserting on. |
| if start_indent is None: |
| return |
| |
| cur_line = start_line + 1 |
| end_line = cur_line |
| # After this loop, function body will be lines [start_line, end_line) |
| while cur_line < len(self.source): |
| cur_indent = self.indentation_level(cur_line) |
| if cur_indent is None: |
| # Consume the line, but don't mark it as belonging to the function yet. |
| cur_line += 1 |
| elif cur_indent > start_indent: |
| # A non-blank line that belongs to the function. |
| cur_line += 1 |
| end_line = cur_line |
| else: |
| # We reached a line outside the function definition. |
| break |
| |
| is_typed = defn.type is not None |
| for line in range(start_line, end_line): |
| old_indent, _ = self.lines_covered[line] |
| # If there was an old indent level for this line, and the new |
| # level isn't increasing the indentation, ignore it. |
| # This is to be defensive against funniness in our line numbers, |
| # which are not always reliable. |
| if old_indent <= start_indent: |
| self.lines_covered[line] = (start_indent, is_typed) |
| |
| # Visit the body, in case there are nested functions |
| super().visit_func_def(defn) |
| |
| |
| class LineCoverageReporter(AbstractReporter): |
| """Exact line coverage reporter. |
| |
| This reporter writes a JSON dictionary with one field 'lines' to |
| the file 'coverage.json' in the specified report directory. The |
| value of that field is a dictionary which associates to each |
| source file's absolute pathname the list of line numbers that |
| belong to typed functions in that file. |
| """ |
| |
| def __init__(self, reports: Reports, output_dir: str) -> None: |
| super().__init__(reports, output_dir) |
| self.lines_covered: dict[str, list[int]] = {} |
| |
| def on_file( |
| self, |
| tree: MypyFile, |
| modules: dict[str, MypyFile], |
| type_map: dict[Expression, Type], |
| options: Options, |
| ) -> None: |
| with open(tree.path) as f: |
| tree_source = f.readlines() |
| |
| coverage_visitor = LineCoverageVisitor(tree_source) |
| tree.accept(coverage_visitor) |
| |
| covered_lines = [] |
| for line_number, (_, typed) in enumerate(coverage_visitor.lines_covered): |
| if typed: |
| covered_lines.append(line_number + 1) |
| |
| self.lines_covered[os.path.abspath(tree.path)] = covered_lines |
| |
| def on_finish(self) -> None: |
| with open(os.path.join(self.output_dir, "coverage.json"), "w") as f: |
| json.dump({"lines": self.lines_covered}, f) |
| |
| |
| register_reporter("linecoverage", LineCoverageReporter) |
| |
| |
| class FileInfo: |
| def __init__(self, name: str, module: str) -> None: |
| self.name = name |
| self.module = module |
| self.counts = [0] * len(stats.precision_names) |
| |
| def total(self) -> int: |
| return sum(self.counts) |
| |
| def attrib(self) -> dict[str, str]: |
| return {name: str(val) for name, val in sorted(zip(stats.precision_names, self.counts))} |
| |
| |
| class MemoryXmlReporter(AbstractReporter): |
| """Internal reporter that generates XML in memory. |
| |
| This is used by all other XML-based reporters to avoid duplication. |
| """ |
| |
| def __init__(self, reports: Reports, output_dir: str) -> None: |
| super().__init__(reports, output_dir) |
| |
| self.xslt_html_path = os.path.join(reports.data_dir, "xml", "mypy-html.xslt") |
| self.xslt_txt_path = os.path.join(reports.data_dir, "xml", "mypy-txt.xslt") |
| self.css_html_path = os.path.join(reports.data_dir, "xml", "mypy-html.css") |
| xsd_path = os.path.join(reports.data_dir, "xml", "mypy.xsd") |
| self.schema = etree.XMLSchema(etree.parse(xsd_path)) |
| self.last_xml: Any | None = None |
| self.files: list[FileInfo] = [] |
| |
| # XML doesn't like control characters, but they are sometimes |
| # legal in source code (e.g. comments, string literals). |
| # Tabs (#x09) are allowed in XML content. |
| control_fixer: Final = str.maketrans("".join(chr(i) for i in range(32) if i != 9), "?" * 31) |
| |
| def on_file( |
| self, |
| tree: MypyFile, |
| modules: dict[str, MypyFile], |
| type_map: dict[Expression, Type], |
| options: Options, |
| ) -> None: |
| self.last_xml = None |
| |
| try: |
| path = os.path.relpath(tree.path) |
| except ValueError: |
| return |
| |
| if should_skip_path(path) or os.path.isdir(path): |
| return # `path` can sometimes be a directory, see #11334 |
| |
| visitor = stats.StatisticsVisitor( |
| inferred=True, |
| filename=tree.fullname, |
| modules=modules, |
| typemap=type_map, |
| all_nodes=True, |
| ) |
| tree.accept(visitor) |
| |
| root = etree.Element("mypy-report-file", name=path, module=tree._fullname) |
| doc = etree.ElementTree(root) |
| file_info = FileInfo(path, tree._fullname) |
| |
| for lineno, line_text in iterate_python_lines(path): |
| status = visitor.line_map.get(lineno, stats.TYPE_EMPTY) |
| file_info.counts[status] += 1 |
| etree.SubElement( |
| root, |
| "line", |
| any_info=self._get_any_info_for_line(visitor, lineno), |
| content=line_text.rstrip("\n").translate(self.control_fixer), |
| number=str(lineno), |
| precision=stats.precision_names[status], |
| ) |
| # Assumes a layout similar to what XmlReporter uses. |
| xslt_path = os.path.relpath("mypy-html.xslt", path) |
| transform_pi = etree.ProcessingInstruction( |
| "xml-stylesheet", f'type="text/xsl" href="{pathname2url(xslt_path)}"' |
| ) |
| root.addprevious(transform_pi) |
| self.schema.assertValid(doc) |
| |
| self.last_xml = doc |
| self.files.append(file_info) |
| |
| @staticmethod |
| def _get_any_info_for_line(visitor: stats.StatisticsVisitor, lineno: int) -> str: |
| if lineno in visitor.any_line_map: |
| result = "Any Types on this line: " |
| counter: collections.Counter[int] = collections.Counter() |
| for typ in visitor.any_line_map[lineno]: |
| counter[typ.type_of_any] += 1 |
| for any_type, occurrences in counter.items(): |
| result += f"\n{type_of_any_name_map[any_type]} (x{occurrences})" |
| return result |
| else: |
| return "No Anys on this line!" |
| |
| def on_finish(self) -> None: |
| self.last_xml = None |
| # index_path = os.path.join(self.output_dir, 'index.xml') |
| output_files = sorted(self.files, key=lambda x: x.module) |
| |
| root = etree.Element("mypy-report-index", name="index") |
| doc = etree.ElementTree(root) |
| |
| for file_info in output_files: |
| etree.SubElement( |
| root, |
| "file", |
| file_info.attrib(), |
| module=file_info.module, |
| name=pathname2url(file_info.name), |
| total=str(file_info.total()), |
| ) |
| xslt_path = os.path.relpath("mypy-html.xslt", ".") |
| transform_pi = etree.ProcessingInstruction( |
| "xml-stylesheet", f'type="text/xsl" href="{pathname2url(xslt_path)}"' |
| ) |
| root.addprevious(transform_pi) |
| self.schema.assertValid(doc) |
| |
| self.last_xml = doc |
| |
| |
| register_reporter("memory-xml", MemoryXmlReporter, needs_lxml=True) |
| |
| |
| def get_line_rate(covered_lines: int, total_lines: int) -> str: |
| if total_lines == 0: |
| return str(1.0) |
| else: |
| return f"{covered_lines / total_lines:.4f}" |
| |
| |
| class CoberturaPackage: |
| """Container for XML and statistics mapping python modules to Cobertura package.""" |
| |
| def __init__(self, name: str) -> None: |
| self.name = name |
| self.classes: dict[str, Any] = {} |
| self.packages: dict[str, CoberturaPackage] = {} |
| self.total_lines = 0 |
| self.covered_lines = 0 |
| |
| def as_xml(self) -> Any: |
| package_element = etree.Element("package", complexity="1.0", name=self.name) |
| package_element.attrib["branch-rate"] = "0" |
| package_element.attrib["line-rate"] = get_line_rate(self.covered_lines, self.total_lines) |
| classes_element = etree.SubElement(package_element, "classes") |
| for class_name in sorted(self.classes): |
| classes_element.append(self.classes[class_name]) |
| self.add_packages(package_element) |
| return package_element |
| |
| def add_packages(self, parent_element: Any) -> None: |
| if self.packages: |
| packages_element = etree.SubElement(parent_element, "packages") |
| for package in sorted(self.packages.values(), key=attrgetter("name")): |
| packages_element.append(package.as_xml()) |
| |
| |
| class CoberturaXmlReporter(AbstractReporter): |
| """Reporter for generating Cobertura compliant XML.""" |
| |
| def __init__(self, reports: Reports, output_dir: str) -> None: |
| super().__init__(reports, output_dir) |
| |
| self.root = etree.Element("coverage", timestamp=str(int(time.time())), version=__version__) |
| self.doc = etree.ElementTree(self.root) |
| self.root_package = CoberturaPackage(".") |
| |
| def on_file( |
| self, |
| tree: MypyFile, |
| modules: dict[str, MypyFile], |
| type_map: dict[Expression, Type], |
| options: Options, |
| ) -> None: |
| path = os.path.relpath(tree.path) |
| visitor = stats.StatisticsVisitor( |
| inferred=True, |
| filename=tree.fullname, |
| modules=modules, |
| typemap=type_map, |
| all_nodes=True, |
| ) |
| tree.accept(visitor) |
| |
| class_name = os.path.basename(path) |
| file_info = FileInfo(path, tree._fullname) |
| class_element = etree.Element("class", complexity="1.0", filename=path, name=class_name) |
| etree.SubElement(class_element, "methods") |
| lines_element = etree.SubElement(class_element, "lines") |
| |
| class_lines_covered = 0 |
| class_total_lines = 0 |
| for lineno, _ in iterate_python_lines(path): |
| status = visitor.line_map.get(lineno, stats.TYPE_EMPTY) |
| hits = 0 |
| branch = False |
| if status == stats.TYPE_EMPTY: |
| continue |
| class_total_lines += 1 |
| if status != stats.TYPE_ANY: |
| class_lines_covered += 1 |
| hits = 1 |
| if status == stats.TYPE_IMPRECISE: |
| branch = True |
| file_info.counts[status] += 1 |
| line_element = etree.SubElement( |
| lines_element, |
| "line", |
| branch=str(branch).lower(), |
| hits=str(hits), |
| number=str(lineno), |
| precision=stats.precision_names[status], |
| ) |
| if branch: |
| line_element.attrib["condition-coverage"] = "50% (1/2)" |
| class_element.attrib["branch-rate"] = "0" |
| class_element.attrib["line-rate"] = get_line_rate(class_lines_covered, class_total_lines) |
| # parent_module is set to whichever module contains this file. For most files, we want |
| # to simply strip the last element off of the module. But for __init__.py files, |
| # the module == the parent module. |
| parent_module = file_info.module.rsplit(".", 1)[0] |
| if file_info.name.endswith("__init__.py"): |
| parent_module = file_info.module |
| |
| if parent_module not in self.root_package.packages: |
| self.root_package.packages[parent_module] = CoberturaPackage(parent_module) |
| current_package = self.root_package.packages[parent_module] |
| packages_to_update = [self.root_package, current_package] |
| for package in packages_to_update: |
| package.total_lines += class_total_lines |
| package.covered_lines += class_lines_covered |
| current_package.classes[class_name] = class_element |
| |
| def on_finish(self) -> None: |
| self.root.attrib["line-rate"] = get_line_rate( |
| self.root_package.covered_lines, self.root_package.total_lines |
| ) |
| self.root.attrib["branch-rate"] = "0" |
| sources = etree.SubElement(self.root, "sources") |
| source_element = etree.SubElement(sources, "source") |
| source_element.text = os.getcwd() |
| self.root_package.add_packages(self.root) |
| out_path = os.path.join(self.output_dir, "cobertura.xml") |
| self.doc.write(out_path, encoding="utf-8", pretty_print=True) |
| print("Generated Cobertura report:", os.path.abspath(out_path)) |
| |
| |
| register_reporter("cobertura-xml", CoberturaXmlReporter, needs_lxml=True) |
| |
| |
| class AbstractXmlReporter(AbstractReporter): |
| """Internal abstract class for reporters that work via XML.""" |
| |
| def __init__(self, reports: Reports, output_dir: str) -> None: |
| super().__init__(reports, output_dir) |
| |
| memory_reporter = reports.add_report("memory-xml", "<memory>") |
| assert isinstance(memory_reporter, MemoryXmlReporter) |
| # The dependency will be called first. |
| self.memory_xml = memory_reporter |
| |
| |
| class XmlReporter(AbstractXmlReporter): |
| """Public reporter that exports XML. |
| |
| The produced XML files contain a reference to the absolute path |
| of the html transform, so they will be locally viewable in a browser. |
| |
| However, there is a bug in Chrome and all other WebKit-based browsers |
| that makes it fail from file:// URLs but work on http:// URLs. |
| """ |
| |
| def on_file( |
| self, |
| tree: MypyFile, |
| modules: dict[str, MypyFile], |
| type_map: dict[Expression, Type], |
| options: Options, |
| ) -> None: |
| last_xml = self.memory_xml.last_xml |
| if last_xml is None: |
| return |
| path = os.path.relpath(tree.path) |
| if path.startswith(".."): |
| return |
| out_path = os.path.join(self.output_dir, "xml", path + ".xml") |
| stats.ensure_dir_exists(os.path.dirname(out_path)) |
| last_xml.write(out_path, encoding="utf-8") |
| |
| def on_finish(self) -> None: |
| last_xml = self.memory_xml.last_xml |
| assert last_xml is not None |
| out_path = os.path.join(self.output_dir, "index.xml") |
| out_xslt = os.path.join(self.output_dir, "mypy-html.xslt") |
| out_css = os.path.join(self.output_dir, "mypy-html.css") |
| last_xml.write(out_path, encoding="utf-8") |
| shutil.copyfile(self.memory_xml.xslt_html_path, out_xslt) |
| shutil.copyfile(self.memory_xml.css_html_path, out_css) |
| print("Generated XML report:", os.path.abspath(out_path)) |
| |
| |
| register_reporter("xml", XmlReporter, needs_lxml=True) |
| |
| |
| class XsltHtmlReporter(AbstractXmlReporter): |
| """Public reporter that exports HTML via XSLT. |
| |
| This is slightly different than running `xsltproc` on the .xml files, |
| because it passes a parameter to rewrite the links. |
| """ |
| |
| def __init__(self, reports: Reports, output_dir: str) -> None: |
| super().__init__(reports, output_dir) |
| |
| self.xslt_html = etree.XSLT(etree.parse(self.memory_xml.xslt_html_path)) |
| self.param_html = etree.XSLT.strparam("html") |
| |
| def on_file( |
| self, |
| tree: MypyFile, |
| modules: dict[str, MypyFile], |
| type_map: dict[Expression, Type], |
| options: Options, |
| ) -> None: |
| last_xml = self.memory_xml.last_xml |
| if last_xml is None: |
| return |
| path = os.path.relpath(tree.path) |
| if path.startswith(".."): |
| return |
| out_path = os.path.join(self.output_dir, "html", path + ".html") |
| stats.ensure_dir_exists(os.path.dirname(out_path)) |
| transformed_html = bytes(self.xslt_html(last_xml, ext=self.param_html)) |
| with open(out_path, "wb") as out_file: |
| out_file.write(transformed_html) |
| |
| def on_finish(self) -> None: |
| last_xml = self.memory_xml.last_xml |
| assert last_xml is not None |
| out_path = os.path.join(self.output_dir, "index.html") |
| out_css = os.path.join(self.output_dir, "mypy-html.css") |
| transformed_html = bytes(self.xslt_html(last_xml, ext=self.param_html)) |
| with open(out_path, "wb") as out_file: |
| out_file.write(transformed_html) |
| shutil.copyfile(self.memory_xml.css_html_path, out_css) |
| print("Generated HTML report (via XSLT):", os.path.abspath(out_path)) |
| |
| |
| register_reporter("xslt-html", XsltHtmlReporter, needs_lxml=True) |
| |
| |
| class XsltTxtReporter(AbstractXmlReporter): |
| """Public reporter that exports TXT via XSLT. |
| |
| Currently this only does the summary, not the individual reports. |
| """ |
| |
| def __init__(self, reports: Reports, output_dir: str) -> None: |
| super().__init__(reports, output_dir) |
| |
| self.xslt_txt = etree.XSLT(etree.parse(self.memory_xml.xslt_txt_path)) |
| |
| def on_file( |
| self, |
| tree: MypyFile, |
| modules: dict[str, MypyFile], |
| type_map: dict[Expression, Type], |
| options: Options, |
| ) -> None: |
| pass |
| |
| def on_finish(self) -> None: |
| last_xml = self.memory_xml.last_xml |
| assert last_xml is not None |
| out_path = os.path.join(self.output_dir, "index.txt") |
| transformed_txt = bytes(self.xslt_txt(last_xml)) |
| with open(out_path, "wb") as out_file: |
| out_file.write(transformed_txt) |
| print("Generated TXT report (via XSLT):", os.path.abspath(out_path)) |
| |
| |
| register_reporter("xslt-txt", XsltTxtReporter, needs_lxml=True) |
| |
| alias_reporter("xslt-html", "html") |
| alias_reporter("xslt-txt", "txt") |
| |
| |
| class LinePrecisionReporter(AbstractReporter): |
| """Report per-module line counts for typing precision. |
| |
| Each line is classified into one of these categories: |
| |
| * precise (fully type checked) |
| * imprecise (Any types in a type component, such as List[Any]) |
| * any (something with an Any type, implicit or explicit) |
| * empty (empty line, comment or docstring) |
| * unanalyzed (mypy considers line unreachable) |
| |
| The meaning of these categories varies slightly depending on |
| context. |
| """ |
| |
| def __init__(self, reports: Reports, output_dir: str) -> None: |
| super().__init__(reports, output_dir) |
| self.files: list[FileInfo] = [] |
| |
| def on_file( |
| self, |
| tree: MypyFile, |
| modules: dict[str, MypyFile], |
| type_map: dict[Expression, Type], |
| options: Options, |
| ) -> None: |
| try: |
| path = os.path.relpath(tree.path) |
| except ValueError: |
| return |
| |
| if should_skip_path(path): |
| return |
| |
| visitor = stats.StatisticsVisitor( |
| inferred=True, |
| filename=tree.fullname, |
| modules=modules, |
| typemap=type_map, |
| all_nodes=True, |
| ) |
| tree.accept(visitor) |
| |
| file_info = FileInfo(path, tree._fullname) |
| for lineno, _ in iterate_python_lines(path): |
| status = visitor.line_map.get(lineno, stats.TYPE_EMPTY) |
| file_info.counts[status] += 1 |
| |
| self.files.append(file_info) |
| |
| def on_finish(self) -> None: |
| if not self.files: |
| # Nothing to do. |
| return |
| output_files = sorted(self.files, key=lambda x: x.module) |
| report_file = os.path.join(self.output_dir, "lineprecision.txt") |
| width = max(4, max(len(info.module) for info in output_files)) |
| titles = ("Lines", "Precise", "Imprecise", "Any", "Empty", "Unanalyzed") |
| widths = (width,) + tuple(len(t) for t in titles) |
| fmt = "{:%d} {:%d} {:%d} {:%d} {:%d} {:%d} {:%d}\n" % widths |
| with open(report_file, "w") as f: |
| f.write(fmt.format("Name", *titles)) |
| f.write("-" * (width + 51) + "\n") |
| for file_info in output_files: |
| counts = file_info.counts |
| f.write( |
| fmt.format( |
| file_info.module.ljust(width), |
| file_info.total(), |
| counts[stats.TYPE_PRECISE], |
| counts[stats.TYPE_IMPRECISE], |
| counts[stats.TYPE_ANY], |
| counts[stats.TYPE_EMPTY], |
| counts[stats.TYPE_UNANALYZED], |
| ) |
| ) |
| |
| |
| register_reporter("lineprecision", LinePrecisionReporter) |
| |
| |
| # Reporter class names are defined twice to speed up mypy startup, as this |
| # module is slow to import. Ensure that the two definitions match. |
| assert set(reporter_classes) == set(REPORTER_NAMES) |