Skip to content

Walk up for all config files and handle precedence #18482

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

Merged
merged 11 commits into from
Jan 20, 2025
Merged
Show file tree
Hide file tree
Changes from 4 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
115 changes: 77 additions & 38 deletions mypy/config_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
else:
import tomli as tomllib

from collections.abc import Iterable, Mapping, MutableMapping, Sequence
from collections.abc import Mapping, MutableMapping, Sequence
from typing import Any, Callable, Final, TextIO, Union
from typing_extensions import TypeAlias as _TypeAlias

Expand Down Expand Up @@ -217,6 +217,72 @@ def split_commas(value: str) -> list[str]:
)


def _parse_individual_file(
config_file: str, stderr: TextIO | None = None
) -> tuple[MutableMapping[str, Any], dict[str, _INI_PARSER_CALLABLE], str] | None:

if not os.path.exists(config_file):
return None

parser: MutableMapping[str, Any]
try:
if is_toml(config_file):
with open(config_file, "rb") as f:
toml_data = tomllib.load(f)
# Filter down to just mypy relevant toml keys
toml_data = toml_data.get("tool", {})
if "mypy" not in toml_data:
return None
toml_data = {"mypy": toml_data["mypy"]}
parser = destructure_overrides(toml_data)
config_types = toml_config_types
else:
parser = configparser.RawConfigParser()
parser.read(config_file)
config_types = ini_config_types

except (tomllib.TOMLDecodeError, configparser.Error, ConfigTOMLValueError) as err:
print(f"{config_file}: {err}", file=stderr)
return None

if os.path.basename(config_file) in defaults.SHARED_CONFIG_NAMES and "mypy" not in parser:
return None

return parser, config_types, config_file


def _find_config_file(
stderr: TextIO | None = None,
) -> tuple[MutableMapping[str, Any], dict[str, _INI_PARSER_CALLABLE], str] | None:

current_dir = os.path.abspath(os.getcwd())

while True:
for name in defaults.CONFIG_NAMES + defaults.SHARED_CONFIG_NAMES:
Copy link
Contributor

Choose a reason for hiding this comment

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

Does this mean that the files mypy.ini and .mypy.ini in the parent directories will work?

I don't think it's a good idea. It violates the rule of thumb that those files are used to work only from the current directory.

It's a too-wide change.

I suggest make a precedence for local files, then use pyproject.toml, and then use user-defined files.

config_file = os.path.relpath(os.path.join(current_dir, name))
ret = _parse_individual_file(config_file, stderr)
if ret is None:
continue
return ret

if any(
os.path.exists(os.path.join(current_dir, cvs_root)) for cvs_root in (".git", ".hg")
):
break
parent_dir = os.path.dirname(current_dir)
if parent_dir == current_dir:
break
current_dir = parent_dir

for config_file in defaults.USER_CONFIG_FILES:
ret = _parse_individual_file(config_file, stderr)
if ret is None:
continue
return ret

return None


def parse_config_file(
options: Options,
set_strict_flags: Callable[[], None],
Expand All @@ -233,47 +299,20 @@ def parse_config_file(
stdout = stdout or sys.stdout
stderr = stderr or sys.stderr

if filename is not None:
config_files: tuple[str, ...] = (filename,)
else:
config_files_iter: Iterable[str] = map(os.path.expanduser, defaults.CONFIG_FILES)
config_files = tuple(config_files_iter)

config_parser = configparser.RawConfigParser()

for config_file in config_files:
if not os.path.exists(config_file):
continue
try:
if is_toml(config_file):
with open(config_file, "rb") as f:
toml_data = tomllib.load(f)
# Filter down to just mypy relevant toml keys
toml_data = toml_data.get("tool", {})
if "mypy" not in toml_data:
continue
toml_data = {"mypy": toml_data["mypy"]}
parser: MutableMapping[str, Any] = destructure_overrides(toml_data)
config_types = toml_config_types
else:
config_parser.read(config_file)
parser = config_parser
config_types = ini_config_types
except (tomllib.TOMLDecodeError, configparser.Error, ConfigTOMLValueError) as err:
print(f"{config_file}: {err}", file=stderr)
else:
if config_file in defaults.SHARED_CONFIG_FILES and "mypy" not in parser:
continue
file_read = config_file
options.config_file = file_read
break
else:
ret = (
_parse_individual_file(filename, stderr)
if filename is not None
else _find_config_file(stderr)
)
if ret is None:
return
parser, config_types, file_read = ret

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

if "mypy" not in parser:
if filename or file_read not in defaults.SHARED_CONFIG_FILES:
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"]
Expand Down
41 changes: 3 additions & 38 deletions mypy/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,50 +12,15 @@
# mypy, at least version PYTHON3_VERSION is needed.
PYTHON3_VERSION_MIN: Final = (3, 8) # Keep in sync with typeshed's python support

CACHE_DIR: Final = ".mypy_cache"

def find_pyproject() -> str:
"""Search for file pyproject.toml in the parent directories recursively.

It resolves symlinks, so if there is any symlink up in the tree, it does not respect them

If the file is not found until the root of FS or repository, PYPROJECT_FILE is used
"""

def is_root(current_dir: str) -> bool:
parent = os.path.join(current_dir, os.path.pardir)
return os.path.samefile(current_dir, parent) or any(
os.path.isdir(os.path.join(current_dir, cvs_root)) for cvs_root in (".git", ".hg")
)

# Preserve the original behavior, returning PYPROJECT_FILE if exists
if os.path.isfile(PYPROJECT_FILE) or is_root(os.path.curdir):
return PYPROJECT_FILE

# And iterate over the tree
current_dir = os.path.pardir
while not is_root(current_dir):
config_file = os.path.join(current_dir, PYPROJECT_FILE)
if os.path.isfile(config_file):
return config_file
parent = os.path.join(current_dir, os.path.pardir)
current_dir = parent

return PYPROJECT_FILE

CONFIG_NAMES: Final = ["mypy.ini", ".mypy.ini"]
SHARED_CONFIG_NAMES: Final = ["pyproject.toml", "setup.cfg"]

CACHE_DIR: Final = ".mypy_cache"
CONFIG_FILE: Final = ["mypy.ini", ".mypy.ini"]
PYPROJECT_FILE: Final = "pyproject.toml"
PYPROJECT_CONFIG_FILES: Final = [find_pyproject()]
SHARED_CONFIG_FILES: Final = ["setup.cfg"]
USER_CONFIG_FILES: Final = ["~/.config/mypy/config", "~/.mypy.ini"]
if os.environ.get("XDG_CONFIG_HOME"):
USER_CONFIG_FILES.insert(0, os.path.join(os.environ["XDG_CONFIG_HOME"], "mypy/config"))

CONFIG_FILES: Final = (
CONFIG_FILE + PYPROJECT_CONFIG_FILES + SHARED_CONFIG_FILES + USER_CONFIG_FILES
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe, simply change the order here to shared + pyproject with changing the documentation? https://mypy.readthedocs.io/en/stable/command_line.html#config-file

)

# This must include all reporters defined in mypy.report. This is defined here
# to make reporter names available without importing mypy.report -- this speeds
# up startup.
Expand Down
2 changes: 1 addition & 1 deletion mypy/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,7 @@ def add_invertible_flag(
"--config-file",
help=(
f"Configuration file, must have a [mypy] section "
f"(defaults to {', '.join(defaults.CONFIG_FILES)})"
f"(defaults to {', '.join(defaults.CONFIG_NAMES + defaults.SHARED_CONFIG_NAMES)})"
),
)
add_invertible_flag(
Expand Down
Loading