Skip to content

Add support to pyproject.toml #5208

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

Closed
wants to merge 3 commits into from
Closed
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
185 changes: 149 additions & 36 deletions mypy/config_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
import re
import sys

from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, TextIO
import toml
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, TextIO, MutableMapping
from typing_extensions import Final

from mypy import defaults
Expand Down Expand Up @@ -48,11 +49,22 @@ def split_and_match_files(paths: str) -> List[str]:

Where a path/glob matches no file, we still include the raw path in the resulting list.

Returns a list of file paths
"""

return match_files(paths.split(','))


def match_files(paths: List[str]) -> List[str]:
"""Take list of files/directories (with support for globbing through the glob library).

Where a path/glob matches no file, we still include the raw path in the resulting list.

Returns a list of file paths
"""
expanded_paths = []

for path in paths.split(','):
for path in paths:
path = expand_path(path.strip())
globbed_files = fileglob.glob(path, recursive=True)
if globbed_files:
Expand All @@ -77,7 +89,7 @@ def check_follow_imports(choice: str) -> str:
# sufficient, and we don't have to do anything here. This table
# exists to specify types for values initialized to None or container
# types.
config_types = {
ini_type_converters = {
'python_version': parse_version,
'strict_optional_whitelist': lambda s: s.split(),
'custom_typing_module': str,
Expand All @@ -103,6 +115,16 @@ def check_follow_imports(choice: str) -> str:
} # type: Final


toml_type_converters = {
'python_version': parse_version,
'custom_typeshed_dir': expand_path,
'mypy_path': lambda l: [expand_path(p) for p in l],
'files': match_files,
'cache_dir': expand_path,
'python_executable': expand_path,
} # type: Final


def parse_config_file(options: Options, set_strict_flags: Callable[[], None],
filename: Optional[str],
stdout: Optional[TextIO] = None,
Expand All @@ -113,50 +135,140 @@ def parse_config_file(options: Options, set_strict_flags: Callable[[], None],

If filename is None, fall back to default config files.
"""
stdout = stdout or sys.stdout
if filename is not None:
filename = os.path.expanduser(filename)
if os.path.splitext(filename)[1] == '.toml':
parse_toml_config_file(
options, set_strict_flags, filename, stdout, stderr, explicit=True)
else:
parse_ini_config_file(
options, set_strict_flags, filename, stdout, stderr, explicit=True)
else:
for filename in defaults.CONFIG_FILES:
filename = os.path.expanduser(filename)
if not os.path.isfile(filename):
continue
if os.path.splitext(filename)[1] == '.toml':
parsed = parse_toml_config_file(
options, set_strict_flags, filename, stdout, stderr, explicit=False)
else:
parsed = parse_ini_config_file(
options, set_strict_flags, filename, stdout, stderr, explicit=False)
if parsed:
break


def parse_toml_config_file(options: Options, set_strict_flags: Callable[[], None],
filename: str,
stdout: Optional[TextIO] = None,
stderr: Optional[TextIO] = None,
*,
explicit: bool) -> bool:
stderr = stderr or sys.stderr

if filename is not None:
config_files = (filename,) # type: Tuple[str, ...]
# Load the toml config file.
try:
table = toml.load(filename) # type: MutableMapping[str, Any]
except (TypeError, toml.TomlDecodeError, IOError) as err:
print("%s: %s" % (filename, err), file=stderr)
return False
else:
config_files = tuple(map(os.path.expanduser, defaults.CONFIG_FILES))
options.config_file = filename

parser = configparser.RawConfigParser()
if 'tool' not in table or 'mypy' not in table['tool']:
if explicit:
print("%s: No 'tool.mypy' table in config file" % filename, file=stderr)
return False

for config_file in config_files:
if not os.path.exists(config_file):
continue
try:
parser.read(config_file)
except configparser.Error as err:
print("%s: %s" % (config_file, err), file=stderr)
# Handle the mypy table.
for key, value in table['tool']['mypy'].items():

# Is an option.
if key != 'overrides':

# Is a report directory.
if key.endswith('_report'):
report_type = key[:-7].replace('_', '-')
if report_type in defaults.REPORTER_NAMES:
options.report_dirs[report_type] = table['mypy'][key]
else:
print("%s: Unrecognized report type: %s" %
(filename, key),
file=stderr)
elif key == 'strict':
set_strict_flags()
else:
if key in toml_type_converters:
value = toml_type_converters[key](value) # type: ignore
setattr(options, key, value)

# Read the per-module override sub-tables.
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
for glob, override in value.items():
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 "
"with '*' in some components (e.g spam.*.eggs.*)"
% filename, file=stderr)

values = {}
for subkey, subvalue in override.items():
if subkey.endswith('_report'):
print("Per-module override [%s] should not specify reports (%s)" %
(glob, subkey), file=stderr)
continue
elif subkey not in PER_MODULE_OPTIONS:
print("Per-module tables [%s] should only specify per-module flags (%s)" %
(key, subkey), file=stderr)
continue

if subkey in toml_type_converters:
subvalue = toml_type_converters[subkey](subvalue) # type: ignore
values[subkey] = subvalue

options.per_module_options[glob] = values
return True


def parse_ini_config_file(options: Options, set_strict_flags: Callable[[], None],
filename: str,
stdout: Optional[TextIO] = None,
stderr: Optional[TextIO] = None,
*,
explicit: bool) -> bool:
stderr = stderr or sys.stderr
parser = configparser.RawConfigParser()
retv = False

try:
parser.read(filename)
except configparser.Error as err:
print("%s: %s" % (filename, err), file=stderr)
return retv
else:
return
options.config_file = filename

os.environ['MYPY_CONFIG_FILE_DIR'] = os.path.dirname(
os.path.abspath(config_file))
os.path.abspath(filename))

if 'mypy' not in parser:
if filename or file_read not in defaults.SHARED_CONFIG_FILES:
print("%s: No [mypy] section in config file" % file_read, file=stderr)
if not explicit and filename not in defaults.SHARED_CONFIG_FILES:
print("%s: No [mypy] section in config file" % filename, file=stderr)
else:
retv = True
section = parser['mypy']
prefix = '%s: [%s]: ' % (file_read, 'mypy')
updates, report_dirs = parse_section(prefix, options, set_strict_flags, section, stderr)
prefix = '%s: [%s]: ' % (filename, 'mypy')
updates, report_dirs = parse_ini_section(
prefix, options, set_strict_flags, 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(
retv = True
prefix = '%s: [%s]: ' % (filename, name)
updates, report_dirs = parse_ini_section(
prefix, options, set_strict_flags, section, stderr)
if report_dirs:
print("%sPer-module sections should not specify reports (%s)" %
Expand All @@ -182,13 +294,14 @@ def parse_config_file(options: Options, set_strict_flags: Callable[[], None],
file=stderr)
else:
options.per_module_options[glob] = updates
return retv


def parse_section(prefix: str, template: Options,
set_strict_flags: Callable[[], None],
section: Mapping[str, str],
stderr: TextIO = sys.stderr
) -> Tuple[Dict[str, object], Dict[str, str]]:
def parse_ini_section(prefix: str, template: Options,
set_strict_flags: Callable[[], None],
section: Mapping[str, str],
stderr: TextIO = sys.stderr
) -> Tuple[Dict[str, object], Dict[str, str]]:
"""Parse one section of a config file.

Returns a dict of option values encountered, and a dict of report directories.
Expand All @@ -198,8 +311,8 @@ def parse_section(prefix: str, template: Options,
for key in section:
invert = False
options_key = key
if key in config_types:
ct = config_types[key]
if key in ini_type_converters:
ct = ini_type_converters[key]
else:
dv = None
# We have to keep new_semantic_analyzer in Options
Expand Down Expand Up @@ -361,7 +474,7 @@ def set_strict_flags() -> None:
nonlocal strict_found
strict_found = True

new_sections, reports = parse_section(
new_sections, reports = parse_ini_section(
'', template, set_strict_flags, parser['dummy'], stderr=stderr)
errors.extend((lineno, x) for x in stderr.getvalue().strip().split('\n') if x)
if reports:
Expand Down
11 changes: 8 additions & 3 deletions mypy/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,18 @@
PYTHON3_VERSION = (3, 6) # type: Final
PYTHON3_VERSION_MIN = (3, 4) # type: Final
CACHE_DIR = '.mypy_cache' # type: Final
CONFIG_FILE = ['mypy.ini', '.mypy.ini'] # type: Final
INI_CONFIG_FILES = ['mypy.ini', '.mypy.ini'] # type: Final
SHARED_CONFIG_FILES = ['setup.cfg', ] # type: Final
USER_CONFIG_FILES = ['~/.config/mypy/config', '~/.mypy.ini', ] # type: Final
if os.environ.get('XDG_CONFIG_HOME'):
USER_CONFIG_FILES.insert(0, os.path.join(os.environ['XDG_CONFIG_HOME'], 'mypy/config'))

CONFIG_FILES = CONFIG_FILE + SHARED_CONFIG_FILES + USER_CONFIG_FILES # type: Final
TOML_CONFIG_FILES = ['pyproject.toml'] # type: Final
CONFIG_FILES = (
INI_CONFIG_FILES
+ TOML_CONFIG_FILES
+ SHARED_CONFIG_FILES
+ USER_CONFIG_FILES
) # type: Final

# This must include all reporters defined in mypy.report. This is defined here
# to make reporter names available without importing mypy.report -- this speeds
Expand Down
2 changes: 1 addition & 1 deletion mypy/test/testfinegrained.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ def get_options(self,
options.follow_imports = 'error'

for name, _ in testcase.files:
if 'mypy.ini' in name:
if os.path.basename(name) in ['mypy.ini', 'pyproject.toml']:
parse_config_file(options, lambda: None, name)
break

Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ def run(self):
install_requires=['typed_ast >= 1.4.0, < 1.5.0',
'typing_extensions>=3.7.4',
'mypy_extensions >= 0.4.3, < 0.5.0',
'toml >= 0.10.0, < 0.11.0',
],
# Same here.
extras_require={'dmypy': 'psutil >= 4.0'},
Expand Down
52 changes: 41 additions & 11 deletions test-data/unit/cmdline.test
Original file line number Diff line number Diff line change
Expand Up @@ -170,23 +170,53 @@ def f():
except ZeroDivisionError, err:
print err

[case testNoConfigFile]
# cmd: mypy main.py --config-file=
[file mypy.ini]
[case testWarnUnusedIgnores]
# cmd: mypy main.py --config-file=pyproject.toml
[file pyproject.toml]
\[tool.mypy]
warn_unused_ignores = true
[file main.py]
# type: ignore
[out]
main.py:1: error: unused 'type: ignore' comment

[case testMissingTomlMypySection1]
# cmd: mypy main.py --config-file=pyproject.toml
[file pyproject.toml]
[file main.py]
[out]
pyproject.toml: No 'tool.mypy' table in config file
== Return code: 0

[case testMissingTomlMypySection2]
# cmd: mypy main.py
# We shouldn't complain if it wasn't specified as the config file
[file pyproject.toml]
[file main.py]
[out]

[case testMissingTomlMypySection3]
# cmd: mypy main.py
# We shouldn't complain if it wasn't specified as the config file
# And we should pick up the setup.cfg
[file pyproject.toml]
[file setup.cfg]
\[mypy]
warn_unused_ignores = True
warn_unused_ignores = true
[file main.py]
# type: ignore
[out]
main.py:1: error: unused 'type: ignore' comment

[case testPerFileConfigSection]
Copy link
Author

Choose a reason for hiding this comment

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

And changed this one because to increase TOML coverage. Was it right?

Copy link
Member

Choose a reason for hiding this comment

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

I would prefer you create a separate test here if possible.

# cmd: mypy x.py y.py z.py
[file mypy.ini]
\[mypy]
disallow_untyped_defs = True
\[mypy-y]
disallow_untyped_defs = False
\[mypy-z]
disallow_untyped_calls = True
[file pyproject.toml]
\[tool.mypy]
disallow_untyped_defs = true
\[tool.mypy.overrides.y]
disallow_untyped_defs = false
\[tool.mypy.overrides.z]
disallow_untyped_calls = true
[file x.py]
def f(a):
pass
Expand Down