Skip to content

Commit 7ab00b7

Browse files
committed
Add config key to extend config files
1 parent 3f50e3c commit 7ab00b7

File tree

2 files changed

+167
-9
lines changed

2 files changed

+167
-9
lines changed

mypy/config_parser.py

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ def parse_config_file(
292292
stdout: TextIO | None = None,
293293
stderr: TextIO | None = None,
294294
) -> None:
295-
"""Parse a config file into an Options object.
295+
"""Parse a config file into an Options object, following config extend arguments.
296296
297297
Errors are written to stderr but are not fatal.
298298
@@ -301,30 +301,104 @@ def parse_config_file(
301301
stdout = stdout or sys.stdout
302302
stderr = stderr or sys.stderr
303303

304+
ret = _parse_and_extend_config_file(
305+
options=options,
306+
set_strict_flags=set_strict_flags,
307+
filename=filename,
308+
stdout=stdout,
309+
stderr=stderr,
310+
visited=set(),
311+
)
312+
313+
if ret is None:
314+
return
315+
316+
file_read, mypy_updates, mypy_report_dirs, module_updates = ret
317+
318+
options.config_file = file_read
319+
os.environ["MYPY_CONFIG_FILE_DIR"] = os.path.dirname(os.path.abspath(file_read))
320+
321+
for k, v in mypy_updates.items():
322+
setattr(options, k, v)
323+
324+
options.report_dirs.update(mypy_report_dirs)
325+
326+
for glob, updates in module_updates.items():
327+
options.per_module_options[glob] = updates
328+
329+
330+
def _merge_updates(existing: dict[str, object], new: dict[str, object]) -> None:
331+
existing["disable_error_code"] = list(
332+
set(existing.get("disable_error_code", []) + new.pop("disable_error_code"))
333+
)
334+
existing["enable_error_code"] = list(
335+
set(existing.get("enable_error_code", []) + new.pop("enable_error_code"))
336+
)
337+
existing.update(new)
338+
339+
340+
def _parse_and_extend_config_file(
341+
options: Options,
342+
set_strict_flags: Callable[[], None],
343+
filename: str | None,
344+
stdout: TextIO,
345+
stderr: TextIO,
346+
visited: set[str],
347+
) -> tuple[str, dict[str, object], dict[str, str], dict[str, dict[str, object]]] | None:
304348
ret = (
305349
_parse_individual_file(filename, stderr)
306350
if filename is not None
307351
else _find_config_file(stderr)
308352
)
309353
if ret is None:
310-
return
354+
return None
311355
parser, config_types, file_read = ret
312356

313-
options.config_file = file_read
314-
os.environ["MYPY_CONFIG_FILE_DIR"] = os.path.dirname(os.path.abspath(file_read))
357+
abs_file_read = os.path.abspath(file_read)
358+
if abs_file_read in visited:
359+
print(f"Circular extend detected: {abs_file_read}", file=stderr)
360+
return None
361+
visited.add(abs_file_read)
362+
363+
mypy_updates: dict[str, object] = {}
364+
mypy_report_dirs: dict[str, str] = {}
365+
module_updates: dict[str, dict[str, object]] = {}
315366

316367
if "mypy" not in parser:
317368
if filename or os.path.basename(file_read) not in defaults.SHARED_CONFIG_NAMES:
318369
print(f"{file_read}: No [mypy] section in config file", file=stderr)
319370
else:
320371
section = parser["mypy"]
372+
373+
extend = parser["mypy"].pop("extend", None)
374+
if extend:
375+
cwd = os.getcwd()
376+
try:
377+
# process extend relative to the directory where we found current config
378+
os.chdir(os.path.dirname(abs_file_read))
379+
parse_ret = _parse_and_extend_config_file(
380+
options=options,
381+
set_strict_flags=set_strict_flags,
382+
filename=os.path.abspath(expand_path(extend)),
383+
stdout=stdout,
384+
stderr=stderr,
385+
visited=visited,
386+
)
387+
finally:
388+
os.chdir(cwd)
389+
390+
if parse_ret is None:
391+
print(f"{extend} is not a valid path to extend from {abs_file_read}", file=stderr)
392+
else:
393+
_, mypy_updates, mypy_report_dirs, module_updates = parse_ret
394+
321395
prefix = f"{file_read}: [mypy]: "
322396
updates, report_dirs = parse_section(
323397
prefix, options, set_strict_flags, section, config_types, stderr
324398
)
325-
for k, v in updates.items():
326-
setattr(options, k, v)
327-
options.report_dirs.update(report_dirs)
399+
# extend and overwrite existing values with new ones
400+
_merge_updates(mypy_updates, updates)
401+
mypy_report_dirs.update(report_dirs)
328402

329403
for name, section in parser.items():
330404
if name.startswith("mypy-"):
@@ -367,7 +441,10 @@ def parse_config_file(
367441
file=stderr,
368442
)
369443
else:
370-
options.per_module_options[glob] = updates
444+
# extend and overwrite existing values with new ones
445+
_merge_updates(module_updates.setdefault(glob, {}), updates)
446+
447+
return file_read, mypy_updates, mypy_report_dirs, module_updates
371448

372449

373450
def get_prefix(file_read: str, name: str) -> str:

mypy/test/test_config_parser.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
from __future__ import annotations
22

33
import contextlib
4+
import io
45
import os
56
import tempfile
67
import unittest
78
from collections.abc import Iterator
89
from pathlib import Path
910

10-
from mypy.config_parser import _find_config_file
11+
from mypy.config_parser import _find_config_file, parse_config_file
1112
from mypy.defaults import CONFIG_NAMES, SHARED_CONFIG_NAMES
13+
from mypy.options import Options
1214

1315

1416
@contextlib.contextmanager
@@ -128,3 +130,82 @@ def test_precedence_missing_section(self) -> None:
128130
result = _find_config_file()
129131
assert result is not None
130132
assert Path(result[2]).resolve() == parent_mypy.resolve()
133+
134+
135+
class ExtendConfigFileSuite(unittest.TestCase):
136+
137+
def test_extend_success(self) -> None:
138+
with tempfile.TemporaryDirectory() as _tmpdir:
139+
tmpdir = Path(_tmpdir)
140+
with chdir(tmpdir):
141+
pyproject = tmpdir / "pyproject.toml"
142+
write_config(
143+
pyproject,
144+
"[tool.mypy]\n"
145+
'extend = "./folder/mypy.ini"\n'
146+
"strict = false\n"
147+
"[[tool.mypy.overrides]]\n"
148+
'module = "c"\n'
149+
'enable_error_code = ["explicit-override"]\n'
150+
"disallow_untyped_defs = true",
151+
)
152+
folder = tmpdir / "folder"
153+
folder.mkdir()
154+
write_config(
155+
folder / "mypy.ini",
156+
"[mypy]\n"
157+
"strict = True\n"
158+
"ignore_missing_imports_per_module = True\n"
159+
"[mypy-c]\n"
160+
"disallow_incomplete_defs = True",
161+
)
162+
163+
options = Options()
164+
strict_option_set = False
165+
166+
def set_strict_flags() -> None:
167+
nonlocal strict_option_set
168+
strict_option_set = True
169+
170+
stdout = io.StringIO()
171+
stderr = io.StringIO()
172+
parse_config_file(options, set_strict_flags, None, stdout, stderr)
173+
174+
assert strict_option_set is True
175+
assert options.ignore_missing_imports_per_module is True
176+
assert options.config_file == str(pyproject.name)
177+
os.environ["MYPY_CONFIG_FILE_DIR"] = str(pyproject.parent)
178+
179+
assert options.per_module_options["c"] == {
180+
"disable_error_code": [],
181+
"enable_error_code": ["explicit-override"],
182+
"disallow_untyped_defs": True,
183+
"disallow_incomplete_defs": True,
184+
}
185+
186+
assert stdout.getvalue() == ""
187+
assert stderr.getvalue() == ""
188+
189+
def test_extend_cyclic(self) -> None:
190+
with tempfile.TemporaryDirectory() as _tmpdir:
191+
tmpdir = Path(_tmpdir)
192+
with chdir(tmpdir):
193+
pyproject = tmpdir / "pyproject.toml"
194+
write_config(pyproject, '[tool.mypy]\nextend = "./folder/mypy.ini"\n')
195+
196+
folder = tmpdir / "folder"
197+
folder.mkdir()
198+
ini = folder / "mypy.ini"
199+
write_config(ini, "[mypy]\nextend = ../pyproject.toml\n")
200+
201+
options = Options()
202+
203+
stdout = io.StringIO()
204+
stderr = io.StringIO()
205+
parse_config_file(options, lambda: None, None, stdout, stderr)
206+
207+
assert stdout.getvalue() == ""
208+
assert stderr.getvalue() == (
209+
f"Circular extend detected: /private{pyproject}\n"
210+
f"../pyproject.toml is not a valid path to extend from /private{ini}\n"
211+
)

0 commit comments

Comments
 (0)