Skip to content

Commit c85d6d4

Browse files
committed
Initial implementation of inline configuration
Implements line configuration using '# mypy: ' comments, following the blueprint I proposed in #2938. It currently finds them just using a regex which means it is possible to pick up a directive spuriously in a string literal or something but honestly I am just not worried about that in practice. Examples of what it looks like in the tests. Fixes #2938. Thoughts?
1 parent 6d34c04 commit c85d6d4

File tree

6 files changed

+229
-15
lines changed

6 files changed

+229
-15
lines changed

mypy/build.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
from mypy.checker import TypeChecker
4444
from mypy.indirection import TypeIndirectionVisitor
4545
from mypy.errors import Errors, CompileError, report_internal_error
46-
from mypy.util import DecodeError, decode_python_encoding, is_sub_path
46+
from mypy.util import DecodeError, decode_python_encoding, is_sub_path, get_mypy_comments
4747
if MYPY:
4848
from mypy.report import Reports # Avoid unconditional slow import
4949
from mypy import moduleinfo
@@ -61,6 +61,7 @@
6161
from mypy.metastore import MetadataStore, FilesystemMetadataStore, SqliteMetadataStore
6262
from mypy.typestate import TypeState, reset_global_state
6363
from mypy.renaming import VariableRenameVisitor
64+
from mypy.config_parser import parse_mypy_comments
6465

6566
from mypy.mypyc_hacks import BuildManagerBase
6667

@@ -1364,6 +1365,11 @@ def write_cache(id: str, path: str, tree: MypyFile,
13641365

13651366
mtime = 0 if bazel else int(st.st_mtime)
13661367
size = st.st_size
1368+
# Note that the options we store in the cache are the options as
1369+
# specified by the command line/config file and *don't* reflect
1370+
# updates made by inline config directives in the file. This is
1371+
# important, or otherwise the options would never match when
1372+
# verifying the cache.
13671373
options = manager.options.clone_for_module(id)
13681374
assert source_hash is not None
13691375
meta = {'id': id,
@@ -1922,6 +1928,8 @@ def parse_file(self) -> None:
19221928
else:
19231929
assert source is not None
19241930
self.source_hash = compute_hash(source)
1931+
1932+
self.parse_inline_configuration(source)
19251933
self.tree = manager.parse_file(self.id, self.xpath, source,
19261934
self.ignore_all or self.options.ignore_errors)
19271935

@@ -1937,6 +1945,16 @@ def parse_file(self) -> None:
19371945

19381946
self.check_blockers()
19391947

1948+
def parse_inline_configuration(self, source: str) -> None:
1949+
# Check for inline mypy: options directive and parse them.
1950+
flags = get_mypy_comments(source)
1951+
if flags:
1952+
changes, config_errors = parse_mypy_comments(flags, self.options)
1953+
self.options = self.options.apply_changes(changes)
1954+
self.manager.errors.set_file(self.xpath, self.id)
1955+
for error in config_errors:
1956+
self.manager.errors.report(-1, 0, error)
1957+
19401958
def semantic_analysis_pass1(self) -> None:
19411959
"""Perform pass 1 of semantic analysis, which happens immediately after parsing.
19421960

mypy/config_parser.py

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import argparse
22
import configparser
33
import glob as fileglob
4+
from io import StringIO
45
import os
56
import re
67
import sys
@@ -116,18 +117,16 @@ def parse_config_file(options: Options, filename: Optional[str],
116117
print("%s: No [mypy] section in config file" % file_read, file=stderr)
117118
else:
118119
section = parser['mypy']
119-
prefix = '%s: [%s]' % (file_read, 'mypy')
120-
updates, report_dirs = parse_section(prefix, options, section,
121-
stdout, stderr)
120+
prefix = '%s: [%s]: ' % (file_read, 'mypy')
121+
updates, report_dirs = parse_section(prefix, options, section, stderr)
122122
for k, v in updates.items():
123123
setattr(options, k, v)
124124
options.report_dirs.update(report_dirs)
125125

126126
for name, section in parser.items():
127127
if name.startswith('mypy-'):
128-
prefix = '%s: [%s]' % (file_read, name)
129-
updates, report_dirs = parse_section(prefix, options, section,
130-
stdout, stderr)
128+
prefix = '%s: [%s]: ' % (file_read, name)
129+
updates, report_dirs = parse_section(prefix, options, section, stderr)
131130
if report_dirs:
132131
print("%s: Per-module sections should not specify reports (%s)" %
133132
(prefix, ', '.join(s + '_report' for s in sorted(report_dirs))),
@@ -156,7 +155,6 @@ def parse_config_file(options: Options, filename: Optional[str],
156155

157156
def parse_section(prefix: str, template: Options,
158157
section: Mapping[str, str],
159-
stdout: TextIO = sys.stdout,
160158
stderr: TextIO = sys.stderr
161159
) -> Tuple[Dict[str, object], Dict[str, str]]:
162160
"""Parse one section of a config file.
@@ -176,17 +174,17 @@ def parse_section(prefix: str, template: Options,
176174
if report_type in defaults.REPORTER_NAMES:
177175
report_dirs[report_type] = section[key]
178176
else:
179-
print("%s: Unrecognized report type: %s" % (prefix, key),
177+
print("%sUnrecognized report type: %s" % (prefix, key),
180178
file=stderr)
181179
continue
182180
if key.startswith('x_'):
183181
continue # Don't complain about `x_blah` flags
184182
elif key == 'strict':
185-
print("%s: Strict mode is not supported in configuration files: specify "
183+
print("%sStrict mode is not supported in configuration files: specify "
186184
"individual flags instead (see 'mypy -h' for the list of flags enabled "
187185
"in strict mode)" % prefix, file=stderr)
188186
else:
189-
print("%s: Unrecognized option: %s = %s" % (prefix, key, section[key]),
187+
print("%sUnrecognized option: %s = %s" % (prefix, key, section[key]),
190188
file=stderr)
191189
continue
192190
ct = type(dv)
@@ -198,29 +196,75 @@ def parse_section(prefix: str, template: Options,
198196
try:
199197
v = ct(section.get(key))
200198
except argparse.ArgumentTypeError as err:
201-
print("%s: %s: %s" % (prefix, key, err), file=stderr)
199+
print("%s%s: %s" % (prefix, key, err), file=stderr)
202200
continue
203201
else:
204202
print("%s: Don't know what type %s should have" % (prefix, key), file=stderr)
205203
continue
206204
except ValueError as err:
207-
print("%s: %s: %s" % (prefix, key, err), file=stderr)
205+
print("%s%s: %s" % (prefix, key, err), file=stderr)
208206
continue
209207
if key == 'cache_dir':
210208
v = os.path.expanduser(v)
211209
if key == 'silent_imports':
212-
print("%s: silent_imports has been replaced by "
210+
print("%ssilent_imports has been replaced by "
213211
"ignore_missing_imports=True; follow_imports=skip" % prefix, file=stderr)
214212
if v:
215213
if 'ignore_missing_imports' not in results:
216214
results['ignore_missing_imports'] = True
217215
if 'follow_imports' not in results:
218216
results['follow_imports'] = 'skip'
219217
if key == 'almost_silent':
220-
print("%s: almost_silent has been replaced by "
218+
print("%salmost_silent has been replaced by "
221219
"follow_imports=error" % prefix, file=stderr)
222220
if v:
223221
if 'follow_imports' not in results:
224222
results['follow_imports'] = 'error'
225223
results[key] = v
226224
return results, report_dirs
225+
226+
227+
def mypy_comments_to_config_map(args: List[str], template: Options) -> Dict[str, str]:
228+
"""Rewrite the mypy comment syntax into ini file syntax"""
229+
options = {}
230+
for line in args:
231+
for entry in line.split(', '):
232+
if '=' not in entry:
233+
name = entry
234+
value = None
235+
else:
236+
name, value = entry.split('=', 1)
237+
238+
name = name.replace('-', '_')
239+
if value is None:
240+
if name.startswith('no_') and not hasattr(template, name):
241+
name = name[3:]
242+
value = 'False'
243+
else:
244+
value = 'True'
245+
options[name] = value
246+
247+
return options
248+
249+
250+
def parse_mypy_comments(
251+
args: List[str], template: Options) -> Tuple[Dict[str, object], List[str]]:
252+
"""Parse a collection of inline mypy: configuration comments.
253+
254+
Returns a dictionary of options to be applied and a list of error messages
255+
generated.
256+
"""
257+
258+
# In order to easily match the behavior for bools, we abuse configparser.
259+
# Oddly, the only way to get the SectionProxy object with the getboolean
260+
# method is to create a config parser.
261+
parser = configparser.RawConfigParser()
262+
parser['dummy'] = mypy_comments_to_config_map(args, template)
263+
264+
stderr = StringIO()
265+
sections, reports = parse_section('', template, parser['dummy'], stderr=stderr)
266+
errors = [x for x in stderr.getvalue().strip().split('\n') if x]
267+
if reports:
268+
errors.append("Reports not supported in inline configuration")
269+
270+
return sections, errors

mypy/test/testcheck.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
'check-redefine.test',
8484
'check-literal.test',
8585
'check-newsemanal.test',
86+
'check-inline-config.test',
8687
]
8788

8889
# Tests that use Python 3.8-only AST features (like expression-scoped ignores):

mypy/util.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
ENCODING_RE = \
1919
re.compile(br'([ \t\v]*#.*(\r\n?|\n))??[ \t\v]*#.*coding[:=][ \t]*([-\w.]+)') # type: Final
2020

21+
MYPY_RE = \
22+
re.compile(r'^#.mypy: (.*)$', re.MULTILINE) # type: Final
23+
2124
default_python2_interpreter = \
2225
['python2', 'python', '/usr/bin/python', 'C:\\Python27\\python.exe'] # type: Final
2326

@@ -89,6 +92,10 @@ def decode_python_encoding(source: bytes, pyversion: Tuple[int, int]) -> str:
8992
return source_text
9093

9194

95+
def get_mypy_comments(source: str) -> List[str]:
96+
return list(re.findall(MYPY_RE, source))
97+
98+
9299
_python2_interpreter = None # type: Optional[str]
93100

94101

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
-- Checks for 'mypy: option' directives inside files
2+
3+
[case testInlineSimple1]
4+
# mypy: disallow-any-generics, no-warn-no-return
5+
6+
from typing import List
7+
def foo() -> List: # E: Missing type parameters for generic type
8+
20
9+
10+
[builtins fixtures/list.pyi]
11+
12+
[case testInlineSimple2]
13+
# mypy: disallow-any-generics
14+
# mypy: no-warn-no-return
15+
16+
from typing import List
17+
def foo() -> List: # E: Missing type parameters for generic type
18+
20
19+
20+
[builtins fixtures/list.pyi]
21+
22+
[case testInlineSimple3]
23+
# mypy: disallow-any-generics=true, warn-no-return=0
24+
25+
from typing import List
26+
def foo() -> List: # E: Missing type parameters for generic type
27+
20
28+
29+
[builtins fixtures/list.pyi]
30+
31+
[case testInlineList]
32+
# mypy: disallow-any-generics, always-false=FOO,BAR
33+
34+
from typing import List
35+
36+
def foo(FOO: bool, BAR: bool) -> List: # E: Missing type parameters for generic type
37+
if FOO or BAR:
38+
1+'lol'
39+
return []
40+
41+
[builtins fixtures/list.pyi]
42+
43+
[case testInlineIncremental1]
44+
import a
45+
[file a.py]
46+
# mypy: disallow-any-generics, no-warn-no-return
47+
48+
from typing import List
49+
def foo() -> List:
50+
20
51+
52+
[file a.py.2]
53+
# mypy: no-warn-no-return
54+
55+
from typing import List
56+
def foo() -> List:
57+
20
58+
59+
[file a.py.3]
60+
from typing import List
61+
def foo() -> List:
62+
20
63+
[out]
64+
tmp/a.py:4: error: Missing type parameters for generic type
65+
[out2]
66+
[out3]
67+
tmp/a.py:2: error: Missing return statement
68+
69+
[builtins fixtures/list.pyi]
70+
71+
[case testInlineIncremental2]
72+
# flags2: --disallow-any-generics
73+
import a
74+
[file a.py]
75+
# mypy: no-warn-no-return
76+
77+
from typing import List
78+
def foo() -> List:
79+
20
80+
81+
[file b.py.2]
82+
# no changes to a.py, but flag change should cause recheck
83+
84+
[out]
85+
[out2]
86+
tmp/a.py:4: error: Missing type parameters for generic type
87+
88+
[builtins fixtures/list.pyi]
89+
90+
[case testInlineIncremental3]
91+
import a, b
92+
[file a.py]
93+
# mypy: no-warn-no-return
94+
95+
def foo() -> int:
96+
20
97+
98+
[file b.py]
99+
[file b.py.2]
100+
# no changes to a.py and we want to make sure it isn't rechecked
101+
[out]
102+
[out2]
103+
[rechecked b]
104+
105+
[case testInlineError1]
106+
# mypy: invalid-whatever
107+
[out]
108+
main: error: Unrecognized option: invalid_whatever = True

test-data/unit/fine-grained.test

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8845,3 +8845,39 @@ y = ''
88458845
[out]
88468846
==
88478847
==
8848+
8849+
[case testInlineConfigFineGrained1]
8850+
import a
8851+
[file a.py]
8852+
# mypy: no-warn-no-return
8853+
8854+
from typing import List
8855+
def foo() -> List:
8856+
20
8857+
8858+
[file a.py.2]
8859+
# mypy: disallow-any-generics, no-warn-no-return
8860+
8861+
from typing import List
8862+
def foo() -> List:
8863+
20
8864+
8865+
[file a.py.3]
8866+
# mypy: no-warn-no-return
8867+
8868+
from typing import List
8869+
def foo() -> List:
8870+
20
8871+
8872+
[file a.py.4]
8873+
from typing import List
8874+
def foo() -> List:
8875+
20
8876+
[out]
8877+
==
8878+
a.py:4: error: Missing type parameters for generic type
8879+
==
8880+
==
8881+
a.py:2: error: Missing return statement
8882+
8883+
[builtins fixtures/list.pyi]

0 commit comments

Comments
 (0)