Skip to content

Commit 124a4b5

Browse files
committed
Implement XML-based reports
1 parent 12fdd83 commit 124a4b5

16 files changed

+643
-20
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/

lib-typing/3.2/typing.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@
5151
# Structural checks, a.k.a. protocols.
5252
'Reversible',
5353
'SupportsAbs',
54+
'SupportsBytes',
55+
'SupportsComplex',
5456
'SupportsFloat',
5557
'SupportsInt',
5658
'SupportsRound',

mypy/build.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,13 @@ def build(program_path: str,
151151
if alt_lib_path:
152152
lib_path.insert(0, alt_lib_path)
153153

154-
reports = Reports(data_dir, report_dirs)
154+
if program_text is None:
155+
program_path = program_path or lookup_program(module, lib_path)
156+
program_text = read_program(program_path)
157+
else:
158+
program_path = program_path or '<string>'
159+
160+
reports = Reports(program_path, data_dir, report_dirs)
155161

156162
# Construct a build manager object that performs all the stages of the
157163
# build in the correct order.
@@ -163,12 +169,6 @@ def build(program_path: str,
163169
custom_typing_module=custom_typing_module,
164170
reports=reports)
165171

166-
if program_text is None:
167-
program_path = program_path or lookup_program(module, lib_path)
168-
program_text = read_program(program_path)
169-
else:
170-
program_path = program_path or '<string>'
171-
172172
# Construct information that describes the initial file. __main__ is the
173173
# implicit module id and the import context is empty initially ([]).
174174
info = StateInfo(program_path, module, [], manager)

mypy/main.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ def process_options(args: List[str]) -> Tuple[str, str, str, Options]:
116116
elif args[0] == '--custom-typing' and args[1:]:
117117
options.custom_typing_module = args[1]
118118
args = args[2:]
119-
elif args[0] in ('--html-report', '--old-html-report') and args[1:]:
119+
elif is_report(args[0]) and args[1:]:
120120
report_type = args[0][2:-7]
121121
report_dir = args[1]
122122
options.report_dirs[report_type] = report_dir
@@ -149,6 +149,22 @@ def process_options(args: List[str]) -> Tuple[str, str, str, Options]:
149149
return args[0], None, None, options
150150

151151

152+
# Don't generate this from mypy.reports, not all are meant to be public.
153+
REPORTS = [
154+
'html',
155+
'old-html',
156+
'xslt-html',
157+
'xml',
158+
'txt',
159+
'xslt-txt',
160+
]
161+
162+
def is_report(arg: str) -> bool:
163+
if arg.startswith('--') and arg.endswith('-report'):
164+
report_type = arg[2:-7]
165+
return report_type in REPORTS
166+
return False
167+
152168
def usage(msg: str = None) -> None:
153169
if msg:
154170
sys.stderr.write('%s\n' % msg)
@@ -163,7 +179,7 @@ def usage(msg: str = None) -> None:
163179
Optional arguments:
164180
-h, --help print this help message and exit
165181
--<fmt>-report dir generate a <fmt> report of type precision under dir/
166-
<fmt> may be one of: html, old-html.
182+
<fmt> may be one of: %s
167183
-m mod type check module
168184
-c string type check string
169185
--verbose more verbose messages
@@ -172,7 +188,7 @@ def usage(msg: str = None) -> None:
172188
173189
Environment variables:
174190
MYPYPATH additional module search path
175-
""")
191+
""" % ', '.join(REPORTS))
176192
sys.exit(2)
177193

178194
def version() -> None:

mypy/report.py

Lines changed: 222 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,212 @@ 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+
path = os.path.relpath(tree.path)
196+
if path.startswith('..'):
197+
return
198+
out_path = os.path.join(self.output_dir, 'xml', path + '.xml')
199+
stats.ensure_dir_exists(os.path.dirname(out_path))
200+
last_xml.write(out_path, encoding='utf-8')
201+
202+
def on_finish(self) -> None:
203+
last_xml = self.memory_xml.last_xml
204+
out_path = os.path.join(self.output_dir, 'index.xml')
205+
out_xslt = os.path.join(self.output_dir, 'mypy-html.xslt')
206+
out_css = os.path.join(self.output_dir, 'mypy-html.css')
207+
last_xml.write(out_path, encoding='utf-8')
208+
shutil.copyfile(self.memory_xml.xslt_html_path, out_xslt)
209+
shutil.copyfile(self.memory_xml.css_html_path, out_css)
210+
print('Generated XML report:', os.path.abspath(out_path))
211+
212+
reporter_classes['xml'] = XmlReporter
213+
214+
class XsltHtmlReporter(AbstractXmlReporter):
215+
"""Public reporter that exports HTML via XSLT.
216+
217+
This is slightly different than running `xsltproc` on the .xml files,
218+
because it passes a parameter to rewrite the links.
219+
"""
220+
221+
def __init__(self, reports: Reports, output_dir: str) -> None:
222+
import lxml.etree as etree
223+
224+
super().__init__(reports, output_dir)
225+
226+
self.xslt_html = etree.XSLT(etree.parse(self.memory_xml.xslt_html_path))
227+
self.param_html = etree.XSLT.strparam('html')
228+
229+
def on_file(self, tree: MypyFile, type_map: Dict[Node, Type]) -> None:
230+
last_xml = self.memory_xml.last_xml
231+
if last_xml is None:
232+
return
233+
path = os.path.relpath(tree.path)
234+
if path.startswith('..'):
235+
return
236+
out_path = os.path.join(self.output_dir, 'html', path + '.html')
237+
stats.ensure_dir_exists(os.path.dirname(out_path))
238+
transformed_html = bytes(self.xslt_html(last_xml, ext=self.param_html))
239+
with open(out_path, 'wb') as out_file:
240+
out_file.write(transformed_html)
241+
242+
def on_finish(self) -> None:
243+
last_xml = self.memory_xml.last_xml
244+
out_path = os.path.join(self.output_dir, 'index.html')
245+
out_css = os.path.join(self.output_dir, 'mypy-html.css')
246+
transformed_html = bytes(self.xslt_html(last_xml, ext=self.param_html))
247+
with open(out_path, 'wb') as out_file:
248+
out_file.write(transformed_html)
249+
shutil.copyfile(self.memory_xml.css_html_path, out_css)
250+
print('Generated HTML report (via XSLT):', os.path.abspath(out_path))
251+
252+
reporter_classes['xslt-html'] = XsltHtmlReporter
253+
254+
class XsltTxtReporter(AbstractXmlReporter):
255+
"""Public reporter that exports TXT via XSLT.
256+
257+
Currently this only does the summary, not the individual reports.
258+
"""
259+
260+
def __init__(self, reports: Reports, output_dir: str) -> None:
261+
import lxml.etree as etree
262+
263+
super().__init__(reports, output_dir)
264+
265+
self.xslt_txt = etree.XSLT(etree.parse(self.memory_xml.xslt_txt_path))
266+
267+
def on_file(self, tree: MypyFile, type_map: Dict[Node, Type]) -> None:
268+
pass
269+
270+
def on_finish(self) -> None:
271+
last_xml = self.memory_xml.last_xml
272+
out_path = os.path.join(self.output_dir, 'index.txt')
273+
stats.ensure_dir_exists(os.path.dirname(out_path))
274+
transformed_txt = bytes(self.xslt_txt(last_xml))
275+
with open(out_path, 'wb') as out_file:
276+
out_file.write(transformed_txt)
277+
print('Generated TXT report (via XSLT):', os.path.abspath(out_path))
278+
279+
reporter_classes['xslt-txt'] = XsltTxtReporter
280+
281+
reporter_classes['html'] = reporter_classes['xslt-html']
282+
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:

setup.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ def find_data_files(base, globs):
5151

5252
data_files += find_data_files('stubs', ['*.py', '*.pyi'])
5353

54+
data_files += find_data_files('xml', ['*.xsd', '*.xslt', '*.css'])
55+
5456
classifiers = [
5557
'Development Status :: 2 - Pre-Alpha',
5658
'Environment :: Console',

0 commit comments

Comments
 (0)