Skip to content

Commit 8f52f35

Browse files
committed
Implement XML-based reports
1 parent 7a09b7f commit 8f52f35

File tree

13 files changed

+610
-16
lines changed

13 files changed

+610
-16
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ __pycache__
66
/build
77
/env
88
docs/build/
9+
/out/

lxml/__init__.pyi

Whitespace-only changes.

lxml/etree.pyi

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Hand-written stub for lxml.etree as used by mypy.report.
2+
# This is *far* from complete, and the stubgen-generated ones crash mypy.
3+
# Any use of `Any` below means I couldn't figure out the type.
4+
5+
import typing
6+
from typing import Any, AnyStr, Dict, List, Tuple, Union
7+
8+
9+
class _Element:
10+
def addprevious(self, element: '_Element') -> None:
11+
pass
12+
13+
class _ElementTree:
14+
def write(self,
15+
file: Union[AnyStr, typing.IO],
16+
encoding: AnyStr = None,
17+
method: AnyStr = "xml",
18+
pretty_print: bool = False,
19+
xml_declaration: Any = None,
20+
with_tail: Any = True,
21+
standalone: bool = None,
22+
compression: int = 0,
23+
exclusive: bool = False,
24+
with_comments: bool = True,
25+
inclusive_ns_prefixes: List[AnyStr] = None) -> None:
26+
pass
27+
28+
class _XSLTResultTree:
29+
pass
30+
31+
class _XSLTQuotedStringParam:
32+
pass
33+
34+
class XMLParser:
35+
pass
36+
37+
class XMLSchema:
38+
def __init__(self,
39+
etree: Union[_Element, _ElementTree] = None,
40+
file: Union[AnyStr, typing.IO] = None) -> None:
41+
pass
42+
43+
def assertValid(self,
44+
etree: Union[_Element, _ElementTree]) -> None:
45+
pass
46+
47+
class XSLTAccessControl:
48+
pass
49+
50+
class XSLT:
51+
def __init__(self,
52+
xslt_input: Union[_Element, _ElementTree],
53+
extensions: Dict[Tuple[AnyStr, AnyStr], Any] = None,
54+
regexp: bool = True,
55+
access_control: XSLTAccessControl = None) -> None:
56+
pass
57+
58+
def __call__(self,
59+
_input: Union[_Element, _ElementTree],
60+
profile_run: bool = False,
61+
**kwargs: Union[AnyStr, _XSLTQuotedStringParam]) -> _XSLTResultTree:
62+
pass
63+
64+
@staticmethod
65+
def strparam(s: AnyStr) -> _XSLTQuotedStringParam:
66+
pass
67+
68+
def Element(_tag: AnyStr,
69+
attrib: Dict[AnyStr, AnyStr] = None,
70+
nsmap: Dict[AnyStr, AnyStr] = None,
71+
**extra: AnyStr) -> _Element:
72+
pass
73+
74+
def SubElement(_parent: _Element, _tag: AnyStr,
75+
attrib: Dict[AnyStr, AnyStr] = None,
76+
nsmap: Dict[AnyStr, AnyStr] = None,
77+
**extra: AnyStr) -> _Element:
78+
pass
79+
80+
def ElementTree(element: _Element = None,
81+
file: Union[AnyStr, typing.IO] = None,
82+
parser: XMLParser = None) -> _ElementTree:
83+
pass
84+
85+
def ProcessingInstruction(target: AnyStr, text: AnyStr = None) -> _Element:
86+
pass
87+
88+
def parse(source: Union[AnyStr, typing.IO],
89+
parser: XMLParser = None,
90+
base_url: AnyStr = None) -> _ElementTree:
91+
pass

mypy/build.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,11 @@ def build(program_path: str,
145145
if alt_lib_path:
146146
lib_path.insert(0, alt_lib_path)
147147

148-
reports = Reports(data_dir, report_dirs)
148+
program_path = program_path or lookup_program(module, lib_path)
149+
if program_text is None:
150+
program_text = read_program(program_path)
151+
152+
reports = Reports(program_path, data_dir, report_dirs)
149153

150154
# Construct a build manager object that performs all the stages of the
151155
# build in the correct order.
@@ -157,10 +161,6 @@ def build(program_path: str,
157161
custom_typing_module=custom_typing_module,
158162
reports=reports)
159163

160-
program_path = program_path or lookup_program(module, lib_path)
161-
if program_text is None:
162-
program_text = read_program(program_path)
163-
164164
# Construct information that describes the initial file. __main__ is the
165165
# implicit module id and the import context is empty initially ([]).
166166
info = StateInfo(program_path, module, [], manager)

mypy/report.py

Lines changed: 216 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
"""Classes for producing HTML reports about imprecision."""
22

33
from abc import ABCMeta, abstractmethod
4+
import cgi
5+
import os
6+
import shutil
47

5-
from typing import Callable, Dict, List
8+
from typing import Callable, Dict, List, cast
69

710
from mypy.types import Type
811
from mypy.nodes import MypyFile, Node
@@ -13,17 +16,25 @@
1316

1417

1518
class Reports:
16-
def __init__(self, data_dir: str, report_dirs: Dict[str, str]) -> None:
19+
def __init__(self, main_file: str, data_dir: str, report_dirs: Dict[str, str]) -> None:
20+
self.main_file = main_file
1721
self.data_dir = data_dir
1822
self.reporters = [] # type: List[AbstractReporter]
23+
self.named_reporters = {} # type: Dict[str, AbstractReporter]
1924

2025
for report_type, report_dir in sorted(report_dirs.items()):
2126
self.add_report(report_type, report_dir)
2227

2328
def add_report(self, report_type: str, report_dir: str) -> 'AbstractReporter':
29+
try:
30+
return self.named_reporters[report_type]
31+
except KeyError:
32+
pass
2433
reporter_cls = reporter_classes[report_type]
2534
reporter = reporter_cls(self, report_dir)
2635
self.reporters.append(reporter)
36+
self.named_reporters[report_type] = reporter
37+
return reporter
2738

2839
def file(self, tree: MypyFile, type_map: Dict[Node, Type]) -> None:
2940
for reporter in self.reporters:
@@ -60,4 +71,206 @@ def on_finish(self) -> None:
6071
stats.generate_html_index(self.output_dir)
6172
reporter_classes['old-html'] = OldHtmlReporter
6273

63-
reporter_classes['html'] = reporter_classes['old-html']
74+
class FileInfo:
75+
def __init__(self, name: str, module: str) -> None:
76+
self.name = name
77+
self.module = module
78+
self.counts = [0] * len(stats.precision_names)
79+
80+
def total(self) -> int:
81+
return sum(self.counts)
82+
83+
def attrib(self) -> Dict[str, str]:
84+
return {name: str(val) for name, val in zip(stats.precision_names, self.counts)}
85+
86+
class MemoryXmlReporter(AbstractReporter):
87+
"""Internal reporter that generates XML in memory.
88+
89+
This is used by all other XML-based reporters to avoid duplication.
90+
"""
91+
92+
def __init__(self, reports: Reports, output_dir: str) -> None:
93+
import lxml.etree as etree
94+
95+
super().__init__(reports, output_dir)
96+
97+
self.main_file = reports.main_file
98+
self.xslt_html_path = os.path.join(reports.data_dir, 'xml', 'mypy-html.xslt')
99+
self.xslt_txt_path = os.path.join(reports.data_dir, 'xml', 'mypy-txt.xslt')
100+
self.css_html_path = os.path.join(reports.data_dir, 'xml', 'mypy-html.css')
101+
xsd_path = os.path.join(reports.data_dir, 'xml', 'mypy.xsd')
102+
self.schema = etree.XMLSchema(etree.parse(xsd_path))
103+
self.last_xml = None # type: etree._ElementTree
104+
self.files = [] # type: List[FileInfo]
105+
106+
def on_file(self, tree: MypyFile, type_map: Dict[Node, Type]) -> None:
107+
import lxml.etree as etree
108+
109+
self.last_xml = None
110+
path = os.path.relpath(tree.path)
111+
if stats.is_special_module(path):
112+
return
113+
if path.startswith('..'):
114+
return
115+
if 'stubs' in path.split('/'):
116+
return
117+
118+
visitor = stats.StatisticsVisitor(inferred=True, typemap=type_map, all_nodes=True)
119+
tree.accept(visitor)
120+
121+
root = etree.Element('mypy-report-file', name=path, module=tree._fullname)
122+
doc = etree.ElementTree(root)
123+
file_info = FileInfo(path, tree._fullname)
124+
125+
with open(path) as input_file:
126+
for lineno, line_text in enumerate(input_file, 1):
127+
status = visitor.line_map.get(lineno, stats.TYPE_EMPTY)
128+
file_info.counts[status] += 1
129+
etree.SubElement(root, 'line',
130+
number=str(lineno),
131+
precision=stats.precision_names[status],
132+
content=line_text[:-1])
133+
# Assumes a layout similar to what XmlReporter uses.
134+
xslt_path = os.path.relpath('mypy-html.xslt', path)
135+
xml_pi = etree.ProcessingInstruction('xml', 'version="1.0" encoding="utf-8"')
136+
transform_pi = etree.ProcessingInstruction('xml-stylesheet', 'type="text/xsl" href="%s"' % cgi.escape(xslt_path, True))
137+
root.addprevious(xml_pi)
138+
root.addprevious(transform_pi)
139+
self.schema.assertValid(doc)
140+
141+
self.last_xml = doc
142+
self.files.append(file_info)
143+
144+
def on_finish(self) -> None:
145+
import lxml.etree as etree
146+
147+
self.last_xml = None
148+
index_path = os.path.join(self.output_dir, 'index.xml')
149+
output_files = sorted(self.files, key=lambda x: x.module)
150+
151+
root = etree.Element('mypy-report-index', name=self.main_file)
152+
doc = etree.ElementTree(root)
153+
154+
for file_info in output_files:
155+
etree.SubElement(root, 'file',
156+
file_info.attrib(),
157+
total=str(file_info.total()),
158+
name=file_info.name,
159+
module=file_info.module)
160+
xslt_path = os.path.relpath('mypy-html.xslt', '.')
161+
xml_pi = etree.ProcessingInstruction('xml', 'version="1.0" encoding="utf-8"')
162+
transform_pi = etree.ProcessingInstruction('xml-stylesheet', 'type="text/xsl" href="%s"' % cgi.escape(xslt_path, True))
163+
root.addprevious(xml_pi)
164+
root.addprevious(transform_pi)
165+
self.schema.assertValid(doc)
166+
167+
self.last_xml = doc
168+
169+
reporter_classes['memory-xml'] = MemoryXmlReporter
170+
171+
class AbstractXmlReporter(AbstractReporter):
172+
"""Internal abstract class for reporters that work via XML."""
173+
174+
def __init__(self, reports: Reports, output_dir: str) -> None:
175+
super().__init__(reports, output_dir)
176+
177+
memory_reporter = reports.add_report('memory-xml', '<memory>')
178+
# The dependency will be called first.
179+
self.memory_xml = cast(MemoryXmlReporter, memory_reporter)
180+
181+
class XmlReporter(AbstractXmlReporter):
182+
"""Public reporter that exports XML.
183+
184+
The produced XML files contain a reference to the absolute path
185+
of the html transform, so they will be locally viewable in a browser.
186+
187+
However, there is a bug in Chrome and all other WebKit-based browsers
188+
that makes it fail from file:// URLs but work on http:// URLs.
189+
"""
190+
191+
def on_file(self, tree: MypyFile, type_map: Dict[Node, Type]) -> None:
192+
last_xml = self.memory_xml.last_xml
193+
if last_xml is None:
194+
return
195+
out_path = os.path.join(self.output_dir, 'xml', tree.path + '.xml')
196+
stats.ensure_dir_exists(os.path.dirname(out_path))
197+
last_xml.write(out_path, encoding='utf-8')
198+
199+
def on_finish(self) -> None:
200+
last_xml = self.memory_xml.last_xml
201+
out_path = os.path.join(self.output_dir, 'index.xml')
202+
out_xslt = os.path.join(self.output_dir, 'mypy-html.xslt')
203+
out_css = os.path.join(self.output_dir, 'mypy-html.css')
204+
last_xml.write(out_path, encoding='utf-8')
205+
shutil.copyfile(self.memory_xml.xslt_html_path, out_xslt)
206+
shutil.copyfile(self.memory_xml.css_html_path, out_css)
207+
print('Generated XML report:', os.path.abspath(out_path))
208+
209+
reporter_classes['xml'] = XmlReporter
210+
211+
class XsltHtmlReporter(AbstractXmlReporter):
212+
"""Public reporter that exports HTML via XSLT.
213+
214+
This is slightly different than running `xsltproc` on the .xml files,
215+
because it passes a parameter to rewrite the links.
216+
"""
217+
218+
def __init__(self, reports: Reports, output_dir: str) -> None:
219+
import lxml.etree as etree
220+
221+
super().__init__(reports, output_dir)
222+
223+
self.xslt_html = etree.XSLT(etree.parse(self.memory_xml.xslt_html_path))
224+
self.param_html = etree.XSLT.strparam('html')
225+
226+
def on_file(self, tree: MypyFile, type_map: Dict[Node, Type]) -> None:
227+
last_xml = self.memory_xml.last_xml
228+
if last_xml is None:
229+
return
230+
out_path = os.path.join(self.output_dir, 'html', tree.path + '.html')
231+
stats.ensure_dir_exists(os.path.dirname(out_path))
232+
transformed_html = str(self.xslt_html(last_xml, ext=self.param_html))
233+
with open(out_path, 'wt', encoding='utf-8') as out_file:
234+
out_file.write(transformed_html)
235+
236+
def on_finish(self) -> None:
237+
last_xml = self.memory_xml.last_xml
238+
out_path = os.path.join(self.output_dir, 'index.html')
239+
out_css = os.path.join(self.output_dir, 'mypy-html.css')
240+
transformed_html = str(self.xslt_html(last_xml, ext=self.param_html))
241+
with open(out_path, 'wt', encoding='utf-8') as out_file:
242+
out_file.write(transformed_html)
243+
shutil.copyfile(self.memory_xml.css_html_path, out_css)
244+
print('Generated HTML report (via XSLT):', os.path.abspath(out_path))
245+
246+
reporter_classes['xslt-html'] = XsltHtmlReporter
247+
248+
class XsltTxtReporter(AbstractXmlReporter):
249+
"""Public reporter that exports TXT via XSLT.
250+
251+
Currently this only does the summary, not the individual reports.
252+
"""
253+
254+
def __init__(self, reports: Reports, output_dir: str) -> None:
255+
import lxml.etree as etree
256+
257+
super().__init__(reports, output_dir)
258+
259+
self.xslt_txt = etree.XSLT(etree.parse(self.memory_xml.xslt_txt_path))
260+
261+
def on_file(self, tree: MypyFile, type_map: Dict[Node, Type]) -> None:
262+
pass
263+
264+
def on_finish(self) -> None:
265+
last_xml = self.memory_xml.last_xml
266+
out_path = os.path.join(self.output_dir, 'index.txt')
267+
stats.ensure_dir_exists(os.path.dirname(out_path))
268+
transformed_txt = str(self.xslt_txt(last_xml))
269+
with open(out_path, 'wt', encoding='utf-8') as out_file:
270+
out_file.write(transformed_txt)
271+
print('Generated TXT report (via XSLT):', os.path.abspath(out_path))
272+
273+
reporter_classes['xslt-txt'] = XsltTxtReporter
274+
275+
reporter_classes['html'] = reporter_classes['xslt-html']
276+
reporter_classes['txt'] = reporter_classes['xslt-txt']

mypy/stats.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,17 @@
1818
)
1919

2020

21-
TYPE_PRECISE = 0
22-
TYPE_IMPRECISE = 1
23-
TYPE_ANY = 2
21+
TYPE_EMPTY = 0
22+
TYPE_PRECISE = 1
23+
TYPE_IMPRECISE = 2
24+
TYPE_ANY = 3
25+
26+
precision_names = [
27+
'empty',
28+
'precise',
29+
'imprecise',
30+
'any',
31+
]
2432

2533

2634
class StatisticsVisitor(TraverserVisitor):
@@ -353,7 +361,7 @@ def generate_html_index(output_dir: str) -> None:
353361
append('</body></html>')
354362
with open(path, 'w') as file:
355363
file.writelines(output)
356-
print('Generated HTML report: %s' % os.path.abspath(path))
364+
print('Generated HTML report (old): %s' % os.path.abspath(path))
357365

358366

359367
def ensure_dir_exists(dir: str) -> None:

0 commit comments

Comments
 (0)