Skip to content

Commit 67d0902

Browse files
authored
Support '# mypy: ' comments for inline configuration (#6839)
Implements line configuration using '# mypy: ' comments, following the the discussion in #2938. It currently finds them in a pretty primitive manner 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. Documentation to come in a follow-up PR. Fixes #2938.
1 parent b406de7 commit 67d0902

File tree

6 files changed

+302
-19
lines changed

6 files changed

+302
-19
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

6667
# Switch to True to produce debug output related to fine-grained incremental
@@ -1399,6 +1400,11 @@ def write_cache(id: str, path: str, tree: MypyFile,
13991400

14001401
mtime = 0 if bazel else int(st.st_mtime)
14011402
size = st.st_size
1403+
# Note that the options we store in the cache are the options as
1404+
# specified by the command line/config file and *don't* reflect
1405+
# updates made by inline config directives in the file. This is
1406+
# important, or otherwise the options would never match when
1407+
# verifying the cache.
14021408
options = manager.options.clone_for_module(id)
14031409
assert source_hash is not None
14041410
meta = {'id': id,
@@ -1957,6 +1963,8 @@ def parse_file(self) -> None:
19571963
else:
19581964
assert source is not None
19591965
self.source_hash = compute_hash(source)
1966+
1967+
self.parse_inline_configuration(source)
19601968
self.tree = manager.parse_file(self.id, self.xpath, source,
19611969
self.ignore_all or self.options.ignore_errors)
19621970

@@ -1972,6 +1980,16 @@ def parse_file(self) -> None:
19721980

19731981
self.check_blockers()
19741982

1983+
def parse_inline_configuration(self, source: str) -> None:
1984+
"""Check for inline mypy: options directive and parse them."""
1985+
flags = get_mypy_comments(source)
1986+
if flags:
1987+
changes, config_errors = parse_mypy_comments(flags, self.options)
1988+
self.options = self.options.apply_changes(changes)
1989+
self.manager.errors.set_file(self.xpath, self.id)
1990+
for lineno, error in config_errors:
1991+
self.manager.errors.report(lineno, 0, error)
1992+
19751993
def semantic_analysis_pass1(self) -> None:
19761994
"""Perform pass 1 of semantic analysis, which happens immediately after parsing.
19771995

mypy/config_parser.py

Lines changed: 103 additions & 18 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
@@ -119,24 +120,22 @@ def parse_config_file(options: Options, filename: Optional[str],
119120
print("%s: No [mypy] section in config file" % file_read, file=stderr)
120121
else:
121122
section = parser['mypy']
122-
prefix = '%s: [%s]' % (file_read, 'mypy')
123-
updates, report_dirs = parse_section(prefix, options, section,
124-
stdout, stderr)
123+
prefix = '%s: [%s]: ' % (file_read, 'mypy')
124+
updates, report_dirs = parse_section(prefix, options, section, stderr)
125125
for k, v in updates.items():
126126
setattr(options, k, v)
127127
options.report_dirs.update(report_dirs)
128128

129129
for name, section in parser.items():
130130
if name.startswith('mypy-'):
131-
prefix = '%s: [%s]' % (file_read, name)
132-
updates, report_dirs = parse_section(prefix, options, section,
133-
stdout, stderr)
131+
prefix = '%s: [%s]: ' % (file_read, name)
132+
updates, report_dirs = parse_section(prefix, options, section, stderr)
134133
if report_dirs:
135-
print("%s: Per-module sections should not specify reports (%s)" %
134+
print("%sPer-module sections should not specify reports (%s)" %
136135
(prefix, ', '.join(s + '_report' for s in sorted(report_dirs))),
137136
file=stderr)
138137
if set(updates) - PER_MODULE_OPTIONS:
139-
print("%s: Per-module sections should only specify per-module flags (%s)" %
138+
print("%sPer-module sections should only specify per-module flags (%s)" %
140139
(prefix, ', '.join(sorted(set(updates) - PER_MODULE_OPTIONS))),
141140
file=stderr)
142141
updates = {k: v for k, v in updates.items() if k in PER_MODULE_OPTIONS}
@@ -149,7 +148,7 @@ def parse_config_file(options: Options, filename: Optional[str],
149148

150149
if (any(c in glob for c in '?[]!') or
151150
any('*' in x and x != '*' for x in glob.split('.'))):
152-
print("%s: Patterns must be fully-qualified module names, optionally "
151+
print("%sPatterns must be fully-qualified module names, optionally "
153152
"with '*' in some components (e.g spam.*.eggs.*)"
154153
% prefix,
155154
file=stderr)
@@ -159,7 +158,6 @@ def parse_config_file(options: Options, filename: Optional[str],
159158

160159
def parse_section(prefix: str, template: Options,
161160
section: Mapping[str, str],
162-
stdout: TextIO = sys.stdout,
163161
stderr: TextIO = sys.stderr
164162
) -> Tuple[Dict[str, object], Dict[str, str]]:
165163
"""Parse one section of a config file.
@@ -179,17 +177,17 @@ def parse_section(prefix: str, template: Options,
179177
if report_type in defaults.REPORTER_NAMES:
180178
report_dirs[report_type] = section[key]
181179
else:
182-
print("%s: Unrecognized report type: %s" % (prefix, key),
180+
print("%sUnrecognized report type: %s" % (prefix, key),
183181
file=stderr)
184182
continue
185183
if key.startswith('x_'):
186184
continue # Don't complain about `x_blah` flags
187185
elif key == 'strict':
188-
print("%s: Strict mode is not supported in configuration files: specify "
186+
print("%sStrict mode is not supported in configuration files: specify "
189187
"individual flags instead (see 'mypy -h' for the list of flags enabled "
190188
"in strict mode)" % prefix, file=stderr)
191189
else:
192-
print("%s: Unrecognized option: %s = %s" % (prefix, key, section[key]),
190+
print("%sUnrecognized option: %s = %s" % (prefix, key, section[key]),
193191
file=stderr)
194192
continue
195193
ct = type(dv)
@@ -201,29 +199,116 @@ def parse_section(prefix: str, template: Options,
201199
try:
202200
v = ct(section.get(key))
203201
except argparse.ArgumentTypeError as err:
204-
print("%s: %s: %s" % (prefix, key, err), file=stderr)
202+
print("%s%s: %s" % (prefix, key, err), file=stderr)
205203
continue
206204
else:
207-
print("%s: Don't know what type %s should have" % (prefix, key), file=stderr)
205+
print("%sDon't know what type %s should have" % (prefix, key), file=stderr)
208206
continue
209207
except ValueError as err:
210-
print("%s: %s: %s" % (prefix, key, err), file=stderr)
208+
print("%s%s: %s" % (prefix, key, err), file=stderr)
211209
continue
212210
if key == 'cache_dir':
213211
v = os.path.expanduser(v)
214212
if key == 'silent_imports':
215-
print("%s: silent_imports has been replaced by "
213+
print("%ssilent_imports has been replaced by "
216214
"ignore_missing_imports=True; follow_imports=skip" % prefix, file=stderr)
217215
if v:
218216
if 'ignore_missing_imports' not in results:
219217
results['ignore_missing_imports'] = True
220218
if 'follow_imports' not in results:
221219
results['follow_imports'] = 'skip'
222220
if key == 'almost_silent':
223-
print("%s: almost_silent has been replaced by "
221+
print("%salmost_silent has been replaced by "
224222
"follow_imports=error" % prefix, file=stderr)
225223
if v:
226224
if 'follow_imports' not in results:
227225
results['follow_imports'] = 'error'
228226
results[key] = v
229227
return results, report_dirs
228+
229+
230+
def split_directive(s: str) -> Tuple[List[str], List[str]]:
231+
"""Split s on commas, except during quoted sections.
232+
233+
Returns the parts and a list of error messages."""
234+
parts = []
235+
cur = [] # type: List[str]
236+
errors = []
237+
i = 0
238+
while i < len(s):
239+
if s[i] == ',':
240+
parts.append(''.join(cur).strip())
241+
cur = []
242+
elif s[i] == '"':
243+
i += 1
244+
while i < len(s) and s[i] != '"':
245+
cur.append(s[i])
246+
i += 1
247+
if i == len(s):
248+
errors.append("Unterminated quote in configuration comment")
249+
cur.clear()
250+
else:
251+
cur.append(s[i])
252+
i += 1
253+
if cur:
254+
parts.append(''.join(cur).strip())
255+
256+
return parts, errors
257+
258+
259+
def mypy_comments_to_config_map(line: str,
260+
template: Options) -> Tuple[Dict[str, str], List[str]]:
261+
"""Rewrite the mypy comment syntax into ini file syntax.
262+
263+
Returns
264+
"""
265+
options = {}
266+
entries, errors = split_directive(line)
267+
for entry in entries:
268+
if '=' not in entry:
269+
name = entry
270+
value = None
271+
else:
272+
name, value = [x.strip() for x in entry.split('=', 1)]
273+
274+
name = name.replace('-', '_')
275+
if value is None:
276+
if name.startswith('no_') and not hasattr(template, name):
277+
name = name[3:]
278+
value = 'False'
279+
else:
280+
value = 'True'
281+
options[name] = value
282+
283+
return options, errors
284+
285+
286+
def parse_mypy_comments(
287+
args: List[Tuple[int, str]],
288+
template: Options) -> Tuple[Dict[str, object], List[Tuple[int, str]]]:
289+
"""Parse a collection of inline mypy: configuration comments.
290+
291+
Returns a dictionary of options to be applied and a list of error messages
292+
generated.
293+
"""
294+
295+
errors = [] # type: List[Tuple[int, str]]
296+
sections = {}
297+
298+
for lineno, line in args:
299+
# In order to easily match the behavior for bools, we abuse configparser.
300+
# Oddly, the only way to get the SectionProxy object with the getboolean
301+
# method is to create a config parser.
302+
parser = configparser.RawConfigParser()
303+
options, parse_errors = mypy_comments_to_config_map(line, template)
304+
parser['dummy'] = options
305+
errors.extend((lineno, x) for x in parse_errors)
306+
307+
stderr = StringIO()
308+
new_sections, reports = parse_section('', template, parser['dummy'], stderr=stderr)
309+
errors.extend((lineno, x) for x in stderr.getvalue().strip().split('\n') if x)
310+
if reports:
311+
errors.append((lineno, "Reports not supported in inline configuration"))
312+
sections.update(new_sections)
313+
314+
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: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,20 @@ def decode_python_encoding(source: bytes, pyversion: Tuple[int, int]) -> str:
8989
return source_text
9090

9191

92+
def get_mypy_comments(source: str) -> List[Tuple[int, str]]:
93+
PREFIX = '# mypy: '
94+
# Don't bother splitting up the lines unless we know it is useful
95+
if PREFIX not in source:
96+
return []
97+
lines = source.split('\n')
98+
results = []
99+
for i, line in enumerate(lines):
100+
if line.startswith(PREFIX):
101+
results.append((i + 1, line[len(PREFIX):]))
102+
103+
return results
104+
105+
92106
_python2_interpreter = None # type: Optional[str]
93107

94108

0 commit comments

Comments
 (0)