Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Add config key to extend config files #19135

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鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: master
Choose a base branch
Loading
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
93 changes: 85 additions & 8 deletions 93 mypy/config_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ def parse_config_file(
stdout: TextIO | None = None,
stderr: TextIO | None = None,
) -> None:
"""Parse a config file into an Options object.
"""Parse a config file into an Options object, following config extend arguments.

Errors are written to stderr but are not fatal.

Expand All @@ -301,30 +301,104 @@ def parse_config_file(
stdout = stdout or sys.stdout
stderr = stderr or sys.stderr

ret = _parse_and_extend_config_file(
options=options,
set_strict_flags=set_strict_flags,
filename=filename,
stdout=stdout,
stderr=stderr,
visited=set(),
)

if ret is None:
return

file_read, mypy_updates, mypy_report_dirs, module_updates = ret

options.config_file = file_read
os.environ["MYPY_CONFIG_FILE_DIR"] = os.path.dirname(os.path.abspath(file_read))

for k, v in mypy_updates.items():
setattr(options, k, v)

options.report_dirs.update(mypy_report_dirs)

for glob, updates in module_updates.items():
options.per_module_options[glob] = updates


def _merge_updates(existing: dict[str, object], new: dict[str, object]) -> None:
existing["disable_error_code"] = list(
set(existing.get("disable_error_code", []) + new.pop("disable_error_code"))
)
existing["enable_error_code"] = list(
set(existing.get("enable_error_code", []) + new.pop("enable_error_code"))
)
existing.update(new)


def _parse_and_extend_config_file(
options: Options,
set_strict_flags: Callable[[], None],
filename: str | None,
stdout: TextIO,
stderr: TextIO,
visited: set[str],
) -> tuple[str, dict[str, object], dict[str, str], dict[str, dict[str, object]]] | None:
ret = (
_parse_individual_file(filename, stderr)
if filename is not None
else _find_config_file(stderr)
)
if ret is None:
return
return None
parser, config_types, file_read = ret

options.config_file = file_read
os.environ["MYPY_CONFIG_FILE_DIR"] = os.path.dirname(os.path.abspath(file_read))
abs_file_read = os.path.abspath(file_read)
if abs_file_read in visited:
print(f"Circular extend detected: {abs_file_read}", file=stderr)
return None
visited.add(abs_file_read)

mypy_updates: dict[str, object] = {}
mypy_report_dirs: dict[str, str] = {}
module_updates: dict[str, dict[str, object]] = {}

if "mypy" not in parser:
if filename or os.path.basename(file_read) not in defaults.SHARED_CONFIG_NAMES:
print(f"{file_read}: No [mypy] section in config file", file=stderr)
else:
section = parser["mypy"]

extend = parser["mypy"].pop("extend", None)
Copy link
Author

@hasier hasier May 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[info] I thought about adding extend to the toml/ini templates above, but I was not sure of the way they might be used elsewhere, so it felt like doing something ad-hoc would be more suitable here. Happy to change it if there's another preferred way.

if extend:
cwd = os.getcwd()
try:
# process extend relative to the directory where we found current config
os.chdir(os.path.dirname(abs_file_read))
parse_ret = _parse_and_extend_config_file(
options=options,
set_strict_flags=set_strict_flags,
filename=os.path.abspath(expand_path(extend)),
stdout=stdout,
stderr=stderr,
visited=visited,
)
finally:
os.chdir(cwd)

if parse_ret is None:
print(f"{extend} is not a valid path to extend from {abs_file_read}", file=stderr)
else:
_, mypy_updates, mypy_report_dirs, module_updates = parse_ret

prefix = f"{file_read}: [mypy]: "
updates, report_dirs = parse_section(
prefix, options, set_strict_flags, section, config_types, stderr
)
for k, v in updates.items():
setattr(options, k, v)
options.report_dirs.update(report_dirs)
# extend and overwrite existing values with new ones
_merge_updates(mypy_updates, updates)
mypy_report_dirs.update(report_dirs)

for name, section in parser.items():
if name.startswith("mypy-"):
Expand Down Expand Up @@ -367,7 +441,10 @@ def parse_config_file(
file=stderr,
)
else:
options.per_module_options[glob] = updates
# extend and overwrite existing values with new ones
_merge_updates(module_updates.setdefault(glob, {}), updates)

return file_read, mypy_updates, mypy_report_dirs, module_updates


def get_prefix(file_read: str, name: str) -> str:
Expand Down
83 changes: 82 additions & 1 deletion 83 mypy/test/test_config_parser.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
from __future__ import annotations

import contextlib
import io
import os
import tempfile
import unittest
from collections.abc import Iterator
from pathlib import Path

from mypy.config_parser import _find_config_file
from mypy.config_parser import _find_config_file, parse_config_file
from mypy.defaults import CONFIG_NAMES, SHARED_CONFIG_NAMES
from mypy.options import Options


@contextlib.contextmanager
Expand Down Expand Up @@ -128,3 +130,82 @@ def test_precedence_missing_section(self) -> None:
result = _find_config_file()
assert result is not None
assert Path(result[2]).resolve() == parent_mypy.resolve()


class ExtendConfigFileSuite(unittest.TestCase):

def test_extend_success(self) -> None:
with tempfile.TemporaryDirectory() as _tmpdir:
tmpdir = Path(_tmpdir)
with chdir(tmpdir):
pyproject = tmpdir / "pyproject.toml"
write_config(
pyproject,
"[tool.mypy]\n"
'extend = "./folder/mypy.ini"\n'
"strict = false\n"
"[[tool.mypy.overrides]]\n"
'module = "c"\n'
'enable_error_code = ["explicit-override"]\n'
"disallow_untyped_defs = true",
)
folder = tmpdir / "folder"
folder.mkdir()
write_config(
folder / "mypy.ini",
"[mypy]\n"
"strict = True\n"
"ignore_missing_imports_per_module = True\n"
"[mypy-c]\n"
"disallow_incomplete_defs = True",
)

options = Options()
strict_option_set = False

def set_strict_flags() -> None:
nonlocal strict_option_set
strict_option_set = True

stdout = io.StringIO()
stderr = io.StringIO()
parse_config_file(options, set_strict_flags, None, stdout, stderr)

assert strict_option_set is True
assert options.ignore_missing_imports_per_module is True
assert options.config_file == str(pyproject.name)
os.environ["MYPY_CONFIG_FILE_DIR"] = str(pyproject.parent)

assert options.per_module_options["c"] == {
"disable_error_code": [],
"enable_error_code": ["explicit-override"],
"disallow_untyped_defs": True,
"disallow_incomplete_defs": True,
}

assert stdout.getvalue() == ""
assert stderr.getvalue() == ""

def test_extend_cyclic(self) -> None:
with tempfile.TemporaryDirectory() as _tmpdir:
tmpdir = Path(_tmpdir)
with chdir(tmpdir):
pyproject = tmpdir / "pyproject.toml"
write_config(pyproject, '[tool.mypy]\nextend = "./folder/mypy.ini"\n')

folder = tmpdir / "folder"
folder.mkdir()
ini = folder / "mypy.ini"
write_config(ini, "[mypy]\nextend = ../pyproject.toml\n")

options = Options()

stdout = io.StringIO()
stderr = io.StringIO()
parse_config_file(options, lambda: None, None, stdout, stderr)

assert stdout.getvalue() == ""
assert stderr.getvalue() == (
f"Circular extend detected: /private{pyproject}\n"
f"../pyproject.toml is not a valid path to extend from /private{ini}\n"
)
Loading
Morty Proxy This is a proxified and sanitized view of the page, visit original site.