Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support '# mypy: ' comments for inline configuration #6839

Merged
merged 5 commits into from
May 21, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion mypy/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
from mypy.checker import TypeChecker
from mypy.indirection import TypeIndirectionVisitor
from mypy.errors import Errors, CompileError, report_internal_error
from mypy.util import DecodeError, decode_python_encoding, is_sub_path
from mypy.util import DecodeError, decode_python_encoding, is_sub_path, get_mypy_comments
if MYPY:
from mypy.report import Reports # Avoid unconditional slow import
from mypy import moduleinfo
Expand All @@ -61,6 +61,7 @@
from mypy.metastore import MetadataStore, FilesystemMetadataStore, SqliteMetadataStore
from mypy.typestate import TypeState, reset_global_state
from mypy.renaming import VariableRenameVisitor
from mypy.config_parser import parse_mypy_comments


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

mtime = 0 if bazel else int(st.st_mtime)
size = st.st_size
# Note that the options we store in the cache are the options as
# specified by the command line/config file and *don't* reflect
# updates made by inline config directives in the file. This is
# important, or otherwise the options would never match when
# verifying the cache.
options = manager.options.clone_for_module(id)
assert source_hash is not None
meta = {'id': id,
Expand Down Expand Up @@ -1957,6 +1963,8 @@ def parse_file(self) -> None:
else:
assert source is not None
self.source_hash = compute_hash(source)

self.parse_inline_configuration(source)
self.tree = manager.parse_file(self.id, self.xpath, source,
self.ignore_all or self.options.ignore_errors)

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

self.check_blockers()

def parse_inline_configuration(self, source: str) -> None:
"""Check for inline mypy: options directive and parse them."""
flags = get_mypy_comments(source)
if flags:
changes, config_errors = parse_mypy_comments(flags, self.options)
self.options = self.options.apply_changes(changes)
self.manager.errors.set_file(self.xpath, self.id)
for lineno, error in config_errors:
self.manager.errors.report(lineno, 0, error)

def semantic_analysis_pass1(self) -> None:
"""Perform pass 1 of semantic analysis, which happens immediately after parsing.

Expand Down
121 changes: 103 additions & 18 deletions mypy/config_parser.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import argparse
import configparser
import glob as fileglob
from io import StringIO
import os
import re
import sys
Expand Down Expand Up @@ -116,24 +117,22 @@ def parse_config_file(options: Options, filename: Optional[str],
print("%s: No [mypy] section in config file" % file_read, file=stderr)
else:
section = parser['mypy']
prefix = '%s: [%s]' % (file_read, 'mypy')
updates, report_dirs = parse_section(prefix, options, section,
stdout, stderr)
prefix = '%s: [%s]: ' % (file_read, 'mypy')
updates, report_dirs = parse_section(prefix, options, section, stderr)
for k, v in updates.items():
setattr(options, k, v)
options.report_dirs.update(report_dirs)

for name, section in parser.items():
if name.startswith('mypy-'):
prefix = '%s: [%s]' % (file_read, name)
updates, report_dirs = parse_section(prefix, options, section,
stdout, stderr)
prefix = '%s: [%s]: ' % (file_read, name)
updates, report_dirs = parse_section(prefix, options, section, stderr)
if report_dirs:
print("%s: Per-module sections should not specify reports (%s)" %
print("%sPer-module sections should not specify reports (%s)" %
(prefix, ', '.join(s + '_report' for s in sorted(report_dirs))),
file=stderr)
if set(updates) - PER_MODULE_OPTIONS:
print("%s: Per-module sections should only specify per-module flags (%s)" %
print("%sPer-module sections should only specify per-module flags (%s)" %
(prefix, ', '.join(sorted(set(updates) - PER_MODULE_OPTIONS))),
file=stderr)
updates = {k: v for k, v in updates.items() if k in PER_MODULE_OPTIONS}
Expand All @@ -146,7 +145,7 @@ def parse_config_file(options: Options, filename: Optional[str],

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

def parse_section(prefix: str, template: Options,
section: Mapping[str, str],
stdout: TextIO = sys.stdout,
stderr: TextIO = sys.stderr
) -> Tuple[Dict[str, object], Dict[str, str]]:
"""Parse one section of a config file.
Expand All @@ -176,17 +174,17 @@ def parse_section(prefix: str, template: Options,
if report_type in defaults.REPORTER_NAMES:
report_dirs[report_type] = section[key]
else:
print("%s: Unrecognized report type: %s" % (prefix, key),
print("%sUnrecognized report type: %s" % (prefix, key),
file=stderr)
continue
if key.startswith('x_'):
continue # Don't complain about `x_blah` flags
elif key == 'strict':
print("%s: Strict mode is not supported in configuration files: specify "
print("%sStrict mode is not supported in configuration files: specify "
"individual flags instead (see 'mypy -h' for the list of flags enabled "
"in strict mode)" % prefix, file=stderr)
else:
print("%s: Unrecognized option: %s = %s" % (prefix, key, section[key]),
print("%sUnrecognized option: %s = %s" % (prefix, key, section[key]),
file=stderr)
continue
ct = type(dv)
Expand All @@ -198,29 +196,116 @@ def parse_section(prefix: str, template: Options,
try:
v = ct(section.get(key))
except argparse.ArgumentTypeError as err:
print("%s: %s: %s" % (prefix, key, err), file=stderr)
print("%s%s: %s" % (prefix, key, err), file=stderr)
continue
else:
print("%s: Don't know what type %s should have" % (prefix, key), file=stderr)
print("%sDon't know what type %s should have" % (prefix, key), file=stderr)
continue
except ValueError as err:
print("%s: %s: %s" % (prefix, key, err), file=stderr)
print("%s%s: %s" % (prefix, key, err), file=stderr)
continue
if key == 'cache_dir':
v = os.path.expanduser(v)
if key == 'silent_imports':
print("%s: silent_imports has been replaced by "
print("%ssilent_imports has been replaced by "
"ignore_missing_imports=True; follow_imports=skip" % prefix, file=stderr)
if v:
if 'ignore_missing_imports' not in results:
results['ignore_missing_imports'] = True
if 'follow_imports' not in results:
results['follow_imports'] = 'skip'
if key == 'almost_silent':
print("%s: almost_silent has been replaced by "
print("%salmost_silent has been replaced by "
"follow_imports=error" % prefix, file=stderr)
if v:
if 'follow_imports' not in results:
results['follow_imports'] = 'error'
results[key] = v
return results, report_dirs


def split_directive(s: str) -> Tuple[List[str], List[str]]:
"""Split s on commas, except during quoted sections.

Returns the parts and a list of error messages."""
parts = []
cur = [] # type: List[str]
errors = []
i = 0
while i < len(s):
if s[i] == ',':
parts.append(''.join(cur).strip())
cur = []
elif s[i] == '"':
i += 1
while i < len(s) and s[i] != '"':
msullivan marked this conversation as resolved.
Show resolved Hide resolved
cur.append(s[i])
i += 1
if i == len(s):
errors.append("Unterminated quote in configuration comment")
cur.clear()
else:
cur.append(s[i])
i += 1
if cur:
parts.append(''.join(cur).strip())

return parts, errors


def mypy_comments_to_config_map(line: str,
template: Options) -> Tuple[Dict[str, str], List[str]]:
"""Rewrite the mypy comment syntax into ini file syntax.

Returns
"""
options = {}
entries, errors = split_directive(line)
for entry in entries:
if '=' not in entry:
name = entry
value = None
else:
name, value = [x.strip() for x in entry.split('=', 1)]

name = name.replace('-', '_')
if value is None:
if name.startswith('no_') and not hasattr(template, name):
name = name[3:]
value = 'False'
else:
value = 'True'
options[name] = value

return options, errors


def parse_mypy_comments(
args: List[Tuple[int, str]],
template: Options) -> Tuple[Dict[str, object], List[Tuple[int, str]]]:
"""Parse a collection of inline mypy: configuration comments.

Returns a dictionary of options to be applied and a list of error messages
generated.
"""

errors = [] # type: List[Tuple[int, str]]
sections = {}

for lineno, line in args:
# In order to easily match the behavior for bools, we abuse configparser.
# Oddly, the only way to get the SectionProxy object with the getboolean
# method is to create a config parser.
parser = configparser.RawConfigParser()
options, parse_errors = mypy_comments_to_config_map(line, template)
parser['dummy'] = options
errors.extend((lineno, x) for x in parse_errors)

stderr = StringIO()
new_sections, reports = parse_section('', template, parser['dummy'], stderr=stderr)
errors.extend((lineno, x) for x in stderr.getvalue().strip().split('\n') if x)
if reports:
errors.append((lineno, "Reports not supported in inline configuration"))
sections.update(new_sections)

return sections, errors
1 change: 1 addition & 0 deletions mypy/test/testcheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
'check-redefine.test',
'check-literal.test',
'check-newsemanal.test',
'check-inline-config.test',
]

# Tests that use Python 3.8-only AST features (like expression-scoped ignores):
Expand Down
14 changes: 14 additions & 0 deletions mypy/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,20 @@ def decode_python_encoding(source: bytes, pyversion: Tuple[int, int]) -> str:
return source_text


def get_mypy_comments(source: str) -> List[Tuple[int, str]]:
PREFIX = '# mypy: '
# Don't bother splitting up the lines unless we know it is useful
if PREFIX not in source:
return []
lines = source.split('\n')
results = []
for i, line in enumerate(lines):
if line.startswith(PREFIX):
results.append((i + 1, line[len(PREFIX):]))

return results


_python2_interpreter = None # type: Optional[str]


Expand Down
Loading