diff --git a/mypy/report.py b/mypy/report.py index 885d077d259e..e44dcad1e71e 100644 --- a/mypy/report.py +++ b/mypy/report.py @@ -48,179 +48,94 @@ ] ) -ReporterClasses: _TypeAlias = dict[ - str, tuple[Callable[["Reports", str], "AbstractReporter"], bool] -] +type_of_any_color_map: Final[collections.OrderedDict[int, str]] = collections.OrderedDict( + [ + (TypeOfAny.unannotated, "red"), + (TypeOfAny.explicit, "blue"), + (TypeOfAny.from_unimported_type, "purple"), + (TypeOfAny.from_omitted_generics, "yellow"), + (TypeOfAny.from_error, "orange"), + (TypeOfAny.special_form, "pink"), + (TypeOfAny.implementation_artifact, "gray"), + ] +) -reporter_classes: Final[ReporterClasses] = {} +class FileInfo: + def __init__(self, path: str, module: str) -> None: + self.path = path + self.module = module + self.counts: list[int] = [0] * len(stats.TYPE_NAMES) -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] = {} + def total(self) -> int: + return sum(self.counts) - 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: +def iterate_python_lines(path: str) -> Iterator[tuple[int, str]]: + with open(path, encoding="utf-8") as f: try: - return self.named_reporters[report_type] - except KeyError: + tokens = tokenize.generate_tokens(f.readline) + current_line = 0 + for tok_type, tok_str, (start_line, _), (end_line, _), line in tokens: + if start_line > current_line: + current_line = start_line + yield start_line, line + except tokenize.TokenError: 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 != "": - os.makedirs(output_dir, exist_ok=True) +def should_skip_path(path: str) -> bool: + return path.startswith(("..", "/", "\\")) or os.path.isabs(path) - @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 +reporter_classes: dict[str, type[AbstractReporter]] = {} def register_reporter( - report_name: str, - reporter: Callable[[Reports, str], AbstractReporter], - needs_lxml: bool = False, + name: str, reporter_cls: type[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.""" - if not os.path.isdir(path): # can happen with namespace packages - with tokenize.open(path) as input_file: - yield from enumerate(input_file, 1) + if needs_lxml and not LXML_INSTALLED: + return + reporter_classes[name] = reporter_cls -class FuncCounterVisitor(TraverserVisitor): - def __init__(self) -> None: - super().__init__() - self.counts = [0, 0] +def alias_reporter(old_name: str, new_name: str) -> None: + if old_name in reporter_classes: + reporter_classes[new_name] = reporter_classes[old_name] - def visit_func_def(self, defn: FuncDef) -> None: - self.counts[defn.type is not None] += 1 +class Reports: + def __init__(self, options: Options) -> None: + self.options = options + self.reporters: list[AbstractReporter] = [] + self.output_dirs: dict[str, str] = {} + for report_type, output_dir in options.report_type.items(): + if report_type in reporter_classes: + reporter_cls = reporter_classes[report_type] + self.reporters.append(reporter_cls(self, output_dir)) + self.output_dirs[report_type] = output_dir -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( + def 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). - if not os.path.isdir(tree.path): # can happen with namespace packages - with open(tree.path, "rb") as f: - physical_lines = len(f.readlines()) - else: - physical_lines = 0 - - 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) + 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 AnyExpressionsReporter(AbstractReporter): - """Report frequencies of different kinds of Any types.""" +class AbstractReporter(metaclass=ABCMeta): 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]] = {} + self.reports = reports + self.output_dir = output_dir + @abstractmethod def on_file( self, tree: MypyFile, @@ -228,260 +143,26 @@ def on_file( 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) + pass + @abstractmethod 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 + pass - 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. - """ +class AbstractXmlReporter(AbstractReporter): 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: - if os.path.isdir(tree.path): # can happen with namespace packages - return - - 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))} + self.memory_xml = MemoryXmlReporter(reports, output_dir) 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) + self.last_xml: Any = None + self.xslt_html_path = os.path.join(output_dir, "mypy-html.xslt") + self.xslt_txt_path = os.path.join(output_dir, "mypy-txt.xslt") + self.css_html_path = os.path.join(output_dir, "mypy-html.css") def on_file( self, @@ -490,289 +171,18 @@ def on_file( 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" - self.root.attrib["lines-covered"] = str(self.root_package.covered_lines) - self.root.attrib["lines-valid"] = str(self.root_package.total_lines) - 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", "") - 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") - os.makedirs(os.path.dirname(out_path), exist_ok=True) - last_xml.write(out_path, encoding="utf-8") + 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.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) + pass 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") + if LXML_INSTALLED: + self.xslt_html = etree.XSLT(etree.parse(self.memory_xml.xslt_html_path)) + self.param_html = etree.XSLT.strparam("html") def on_file( self, @@ -781,19 +191,11 @@ def on_file( 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") - os.makedirs(os.path.dirname(out_path), exist_ok=True) - 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) + pass def on_finish(self) -> None: + if not LXML_INSTALLED: + return last_xml = self.memory_xml.last_xml assert last_xml is not None out_path = os.path.join(self.output_dir, "index.html") @@ -809,15 +211,10 @@ def on_finish(self) -> None: 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)) + if LXML_INSTALLED: + self.xslt_txt = etree.XSLT(etree.parse(self.memory_xml.xslt_txt_path)) def on_file( self, @@ -829,6 +226,8 @@ def on_file( pass def on_finish(self) -> None: + if not LXML_INSTALLED: + return last_xml = self.memory_xml.last_xml assert last_xml is not None out_path = os.path.join(self.output_dir, "index.txt") @@ -845,20 +244,6 @@ def on_finish(self) -> None: 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] = [] @@ -896,7 +281,6 @@ def on_file( 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") @@ -925,6 +309,41 @@ def on_finish(self) -> None: 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) +class SarifReporter(AbstractReporter): + """SARIF (Static Analysis Results Interchange Format) v2.1.0 report generator.""" + + def __init__(self, reports: Reports, output_dir: str) -> None: + super().__init__(reports, output_dir) + + def on_file( + self, + tree: MypyFile, + modules: dict[str, MypyFile], + type_map: dict[Expression, Type], + options: Options, + ) -> None: + pass + + def on_finish(self) -> None: + out_path = os.path.join(self.output_dir, "mypy-report.sarif") + sarif_data = { + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "mypy", + "version": __version__, + "informationUri": "https://mypy-lang.org/", + } + }, + "results": [], + } + ], + } + with open(out_path, "w", encoding="utf-8") as f: + json.dump(sarif_data, f, indent=2) + + +register_reporter("sarif", SarifReporter)