Skip to content

Commit 26b5754

Browse files
committed
Support reading config from pyproject.toml
1 parent 1e1b22d commit 26b5754

File tree

5 files changed

+171
-46
lines changed

5 files changed

+171
-46
lines changed

mypy/config_parser.py

Lines changed: 141 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
import re
77
import sys
88

9-
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, TextIO
9+
import toml
10+
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, TextIO, MutableMapping
1011
from typing_extensions import Final
1112

1213
from mypy import defaults
@@ -48,11 +49,22 @@ def split_and_match_files(paths: str) -> List[str]:
4849
4950
Where a path/glob matches no file, we still include the raw path in the resulting list.
5051
52+
Returns a list of file paths
53+
"""
54+
55+
return match_files(paths.split(','))
56+
57+
58+
def match_files(paths: List[str]) -> List[str]:
59+
"""Take list of files/directories (with support for globbing through the glob library).
60+
61+
Where a path/glob matches no file, we still include the raw path in the resulting list.
62+
5163
Returns a list of file paths
5264
"""
5365
expanded_paths = []
5466

55-
for path in paths.split(','):
67+
for path in paths:
5668
path = expand_path(path.strip())
5769
globbed_files = fileglob.glob(path, recursive=True)
5870
if globbed_files:
@@ -67,7 +79,7 @@ def split_and_match_files(paths: str) -> List[str]:
6779
# sufficient, and we don't have to do anything here. This table
6880
# exists to specify types for values initialized to None or container
6981
# types.
70-
config_types = {
82+
ini_type_converters = {
7183
'python_version': parse_version,
7284
'strict_optional_whitelist': lambda s: s.split(),
7385
'custom_typing_module': str,
@@ -88,6 +100,16 @@ def split_and_match_files(paths: str) -> List[str]:
88100
} # type: Final
89101

90102

103+
toml_type_converters = {
104+
'python_version': parse_version,
105+
'custom_typeshed_dir': expand_path,
106+
'mypy_path': lambda l: [expand_path(p) for p in l],
107+
'files': match_files,
108+
'cache_dir': expand_path,
109+
'python_executable': expand_path,
110+
} # type: Final
111+
112+
91113
def parse_config_file(options: Options, set_strict_flags: Callable[[], None],
92114
filename: Optional[str],
93115
stdout: Optional[TextIO] = None,
@@ -98,45 +120,130 @@ def parse_config_file(options: Options, set_strict_flags: Callable[[], None],
98120
99121
If filename is None, fall back to default config files.
100122
"""
101-
stdout = stdout or sys.stdout
123+
if filename is not None:
124+
filename = os.path.expanduser(filename)
125+
if os.path.splitext(filename)[1] == '.toml':
126+
parse_toml_config_file(options, set_strict_flags, filename, stdout, stderr)
127+
else:
128+
parse_ini_config_file(options, set_strict_flags, filename, stdout, stderr)
129+
else:
130+
for filename in defaults.CONFIG_FILES:
131+
filename = os.path.expanduser(filename)
132+
if not os.path.isfile(filename):
133+
continue
134+
if os.path.splitext(filename)[1] == '.toml':
135+
parsed = parse_toml_config_file(options, set_strict_flags,
136+
filename, stdout, stderr)
137+
else:
138+
parsed = parse_ini_config_file(options, set_strict_flags,
139+
filename, stdout, stderr)
140+
if parsed:
141+
break
142+
143+
144+
def parse_toml_config_file(options: Options, set_strict_flags: Callable[[], None],
145+
filename: str,
146+
stdout: Optional[TextIO] = None,
147+
stderr: Optional[TextIO] = None) -> bool:
102148
stderr = stderr or sys.stderr
103149

104-
if filename is not None:
105-
config_files = (filename,) # type: Tuple[str, ...]
150+
# Load the toml config file.
151+
try:
152+
table = toml.load(filename) # type: MutableMapping[str, Any]
153+
except (TypeError, toml.TomlDecodeError, IOError) as err:
154+
print("%s: %s" % (filename, err), file=stderr)
155+
return False
106156
else:
107-
config_files = tuple(map(os.path.expanduser, defaults.CONFIG_FILES))
157+
options.config_file = filename
108158

109-
parser = configparser.RawConfigParser()
159+
if 'mypy' not in table:
160+
print("%s: No [mypy] table in config file" % filename, file=stderr)
161+
return False
110162

111-
for config_file in config_files:
112-
if not os.path.exists(config_file):
113-
continue
114-
try:
115-
parser.read(config_file)
116-
except configparser.Error as err:
117-
print("%s: %s" % (config_file, err), file=stderr)
163+
# Handle the mypy table.
164+
for key, value in table['mypy'].items():
165+
166+
# Is an option.
167+
if key != 'overrides':
168+
169+
# Is a report directory.
170+
if key.endswith('_report'):
171+
report_type = key[:-7].replace('_', '-')
172+
if report_type in defaults.REPORTER_NAMES:
173+
options.report_dirs[report_type] = table['mypy'][key]
174+
else:
175+
print("%s: Unrecognized report type: %s" %
176+
(filename, key),
177+
file=stderr)
178+
elif key == 'strict':
179+
set_strict_flags()
180+
else:
181+
if key in toml_type_converters:
182+
value = toml_type_converters[key](value) # type: ignore
183+
setattr(options, key, value)
184+
185+
# Read the per-module override sub-tables.
118186
else:
119-
file_read = config_file
120-
options.config_file = file_read
121-
break
187+
for glob, override in value.items():
188+
if (any(c in glob for c in '?[]!') or
189+
any('*' in x and x != '*' for x in glob.split('.'))):
190+
print("%s: Patterns must be fully-qualified module names, optionally "
191+
"with '*' in some components (e.g spam.*.eggs.*)"
192+
% filename, file=stderr)
193+
194+
values = {}
195+
for subkey, subvalue in override.items():
196+
if subkey.endswith('_report'):
197+
print("Per-module override [%s] should not specify reports (%s)" %
198+
(glob, subkey), file=stderr)
199+
continue
200+
elif subkey not in PER_MODULE_OPTIONS:
201+
print("Per-module tables [%s] should only specify per-module flags (%s)" %
202+
(key, subkey), file=stderr)
203+
continue
204+
205+
if subkey in toml_type_converters:
206+
subvalue = toml_type_converters[subkey](subvalue) # type: ignore
207+
values[subkey] = subvalue
208+
209+
options.per_module_options[glob] = values
210+
return True
211+
212+
213+
def parse_ini_config_file(options: Options, set_strict_flags: Callable[[], None],
214+
filename: str,
215+
stdout: Optional[TextIO] = None,
216+
stderr: Optional[TextIO] = None) -> bool:
217+
stderr = stderr or sys.stderr
218+
parser = configparser.RawConfigParser()
219+
retv = False
220+
221+
try:
222+
parser.read(filename)
223+
except configparser.Error as err:
224+
print("%s: %s" % (filename, err), file=stderr)
225+
return retv
122226
else:
123-
return
227+
options.config_file = filename
124228

125229
if 'mypy' not in parser:
126-
if filename or file_read not in defaults.SHARED_CONFIG_FILES:
127-
print("%s: No [mypy] section in config file" % file_read, file=stderr)
230+
if filename not in defaults.SHARED_CONFIG_FILES:
231+
print("%s: No [mypy] section in config file" % filename, file=stderr)
128232
else:
233+
retv = True
129234
section = parser['mypy']
130-
prefix = '%s: [%s]: ' % (file_read, 'mypy')
131-
updates, report_dirs = parse_section(prefix, options, set_strict_flags, section, stderr)
235+
prefix = '%s: [%s]: ' % (filename, 'mypy')
236+
updates, report_dirs = parse_ini_section(
237+
prefix, options, set_strict_flags, section, stderr)
132238
for k, v in updates.items():
133239
setattr(options, k, v)
134240
options.report_dirs.update(report_dirs)
135241

136242
for name, section in parser.items():
137243
if name.startswith('mypy-'):
138-
prefix = '%s: [%s]: ' % (file_read, name)
139-
updates, report_dirs = parse_section(
244+
retv = True
245+
prefix = '%s: [%s]: ' % (filename, name)
246+
updates, report_dirs = parse_ini_section(
140247
prefix, options, set_strict_flags, section, stderr)
141248
if report_dirs:
142249
print("%sPer-module sections should not specify reports (%s)" %
@@ -162,13 +269,14 @@ def parse_config_file(options: Options, set_strict_flags: Callable[[], None],
162269
file=stderr)
163270
else:
164271
options.per_module_options[glob] = updates
272+
return retv
165273

166274

167-
def parse_section(prefix: str, template: Options,
168-
set_strict_flags: Callable[[], None],
169-
section: Mapping[str, str],
170-
stderr: TextIO = sys.stderr
171-
) -> Tuple[Dict[str, object], Dict[str, str]]:
275+
def parse_ini_section(prefix: str, template: Options,
276+
set_strict_flags: Callable[[], None],
277+
section: Mapping[str, str],
278+
stderr: TextIO = sys.stderr
279+
) -> Tuple[Dict[str, object], Dict[str, str]]:
172280
"""Parse one section of a config file.
173281
174282
Returns a dict of option values encountered, and a dict of report directories.
@@ -178,8 +286,8 @@ def parse_section(prefix: str, template: Options,
178286
for key in section:
179287
invert = False
180288
options_key = key
181-
if key in config_types:
182-
ct = config_types[key]
289+
if key in ini_type_converters:
290+
ct = ini_type_converters[key]
183291
else:
184292
dv = None
185293
# We have to keep new_semantic_analyzer in Options
@@ -337,7 +445,7 @@ def set_strict_flags() -> None:
337445
nonlocal strict_found
338446
strict_found = True
339447

340-
new_sections, reports = parse_section(
448+
new_sections, reports = parse_ini_section(
341449
'', template, set_strict_flags, parser['dummy'], stderr=stderr)
342450
errors.extend((lineno, x) for x in stderr.getvalue().strip().split('\n') if x)
343451
if reports:

mypy/defaults.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,18 @@
66
PYTHON3_VERSION = (3, 6) # type: Final
77
PYTHON3_VERSION_MIN = (3, 4) # type: Final
88
CACHE_DIR = '.mypy_cache' # type: Final
9-
CONFIG_FILE = 'mypy.ini' # type: Final
9+
INI_CONFIG_FILE = 'mypy.ini' # type: Final
1010
SHARED_CONFIG_FILES = ['setup.cfg', ] # type: Final
1111
USER_CONFIG_FILES = ['~/.config/mypy/config', '~/.mypy.ini', ] # type: Final
1212
if os.environ.get('XDG_CONFIG_HOME'):
1313
USER_CONFIG_FILES.insert(0, os.path.join(os.environ['XDG_CONFIG_HOME'], 'mypy/config'))
14+
TOML_CONFIG_FILE = 'pyproject.toml' # type: Final
15+
CONFIG_FILES = (
16+
[INI_CONFIG_FILE, TOML_CONFIG_FILE]
17+
+ SHARED_CONFIG_FILES
18+
+ USER_CONFIG_FILES
19+
) # type: Final
1420

15-
CONFIG_FILES = [CONFIG_FILE, ] + SHARED_CONFIG_FILES + USER_CONFIG_FILES # type: Final
1621

1722
# This must include all reporters defined in mypy.report. This is defined here
1823
# to make reporter names available without importing mypy.report -- this speeds

mypy/test/testfinegrained.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ def get_options(self,
192192
options.follow_imports = 'error'
193193

194194
for name, _ in testcase.files:
195-
if 'mypy.ini' in name:
195+
if os.path.basename(name) in ['mypy.ini', 'pyproject.toml']:
196196
parse_config_file(options, lambda: None, name)
197197
break
198198

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ def run(self):
194194
install_requires=['typed_ast >= 1.4.0, < 1.5.0',
195195
'typing_extensions>=3.7.4',
196196
'mypy_extensions >= 0.4.3, < 0.5.0',
197+
'toml >= 0.10.0, < 0.11.0',
197198
],
198199
# Same here.
199200
extras_require={'dmypy': 'psutil >= 4.0'},

test-data/unit/cmdline.test

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -170,23 +170,34 @@ def f():
170170
except ZeroDivisionError, err:
171171
print err
172172

173-
[case testNoConfigFile]
174-
# cmd: mypy main.py --config-file=
175-
[file mypy.ini]
173+
[case testWarnUnusedIgnores]
174+
# cmd: mypy main.py --config-file=pyproject.toml
175+
[file pyproject.toml]
176176
\[mypy]
177-
warn_unused_ignores = True
177+
warn_unused_ignores = true
178+
[file main.py]
179+
# type: ignore
180+
[out]
181+
main.py:1: error: unused 'type: ignore' comment
182+
183+
[case testMissingMypySection]
184+
# cmd: mypy main.py --config-file=pyproject.toml
185+
[file pyproject.toml]
178186
[file main.py]
179187
# type: ignore
188+
[out]
189+
pyproject.toml: No [mypy] table in config file
190+
== Return code: 0
180191

181192
[case testPerFileConfigSection]
182193
# cmd: mypy x.py y.py z.py
183-
[file mypy.ini]
194+
[file pyproject.toml]
184195
\[mypy]
185-
disallow_untyped_defs = True
186-
\[mypy-y]
187-
disallow_untyped_defs = False
188-
\[mypy-z]
189-
disallow_untyped_calls = True
196+
disallow_untyped_defs = true
197+
\[mypy.overrides.y]
198+
disallow_untyped_defs = false
199+
\[mypy.overrides.z]
200+
disallow_untyped_calls = true
190201
[file x.py]
191202
def f(a):
192203
pass

0 commit comments

Comments
 (0)