diff --git a/docs/source/command_line.rst b/docs/source/command_line.rst index b1505d7d9668..6312e3fe675f 100644 --- a/docs/source/command_line.rst +++ b/docs/source/command_line.rst @@ -114,9 +114,10 @@ Config file This flag makes mypy read configuration settings from the given file. - By default settings are read from ``mypy.ini``, ``.mypy.ini``, ``pyproject.toml``, or ``setup.cfg`` - in the current directory. Settings override mypy's built-in defaults and - command line flags can override settings. + By default settings are read from ``mypy.ini``, ``.mypy.ini``, ``mypy.toml``, + ``.mypy.toml``, ``pyproject.toml``, or ``setup.cfg`` in the current + directory. Settings override mypy's built-in defaults and command line + flags can override settings. Specifying :option:`--config-file= <--config-file>` (with no filename) will ignore *all* config files. diff --git a/docs/source/config_file.rst b/docs/source/config_file.rst index 50f0da62de98..550bccf60765 100644 --- a/docs/source/config_file.rst +++ b/docs/source/config_file.rst @@ -14,8 +14,10 @@ the following configuration files (in this order): 1. ``mypy.ini`` 2. ``.mypy.ini`` - 3. ``pyproject.toml`` (containing a ``[tool.mypy]`` section) - 4. ``setup.cfg`` (containing a ``[mypy]`` section) + 3. ``mypy.toml`` + 4. ``.mypy.toml`` + 5. ``pyproject.toml`` (containing a ``[tool.mypy]`` section) + 6. ``setup.cfg`` (containing a ``[mypy]`` section) If no configuration file is found by this method, mypy will then look for configuration files in the following locations (in this order): @@ -49,8 +51,15 @@ The configuration file format is the usual section names in square brackets and flag settings of the form `NAME = VALUE`. Comments start with ``#`` characters. -- A section named ``[mypy]`` must be present. This specifies - the global flags. +Mypy also supports TOML configuration in two forms: + +* ``pyproject.toml`` with options under ``[tool.mypy]`` and per-module + overrides under ``[[tool.mypy.overrides]]`` +* ``mypy.toml`` or ``.mypy.toml`` with options at the top level and + per-module overrides under ``[[overrides]]`` + +- In INI-based config files, a section named ``[mypy]`` must be present. + This specifies the global flags. - Additional sections named ``[mypy-PATTERN1,PATTERN2,...]`` may be present, where ``PATTERN1``, ``PATTERN2``, etc., are comma-separated @@ -1280,6 +1289,45 @@ of your repo (or append it to the end of an existing ``pyproject.toml`` file) an ] ignore_missing_imports = true +Using a mypy.toml file +********************** + +``mypy.toml`` and ``.mypy.toml`` are also supported. They use the same +TOML value rules as ``pyproject.toml``, but the mypy options live at the +top level instead of under ``[tool.mypy]``. + +Example ``mypy.toml`` +********************* + +.. code-block:: toml + + # mypy global options: + + python_version = "3.9" + warn_return_any = true + warn_unused_configs = true + exclude = [ + '^file1\.py$', # TOML literal string (single-quotes, no escaping necessary) + "^file2\\.py$", # TOML basic string (double-quotes, backslash and other characters need escaping) + ] + + # mypy per-module options: + + [[overrides]] + module = "mycode.foo.*" + disallow_untyped_defs = true + + [[overrides]] + module = "mycode.bar" + warn_return_any = false + + [[overrides]] + module = [ + "somelibrary", + "some_other_library" + ] + ignore_missing_imports = true + .. _lxml: https://pypi.org/project/lxml/ .. _SQLite: https://www.sqlite.org/ .. _PEP 518: https://www.python.org/dev/peps/pep-0518/ diff --git a/mypy/config_parser.py b/mypy/config_parser.py index 747981916960..9457df356593 100644 --- a/mypy/config_parser.py +++ b/mypy/config_parser.py @@ -245,12 +245,10 @@ def _parse_individual_file( 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: + toml_data = get_mypy_toml_data(config_file, toml_data) + if toml_data is None: return None - toml_data = {"mypy": toml_data["mypy"]} - parser = destructure_overrides(toml_data) + parser = destructure_overrides(toml_data, config_file) config_types = toml_config_types else: parser = configparser.RawConfigParser() @@ -397,20 +395,45 @@ def is_toml(filename: str) -> bool: return filename.lower().endswith(".toml") -def destructure_overrides(toml_data: dict[str, Any]) -> dict[str, Any]: - """Take the new [[tool.mypy.overrides]] section array in the pyproject.toml file, - and convert it back to a flatter structure that the existing config_parser can handle. +def is_pyproject(filename: str) -> bool: + return os.path.basename(filename) == "pyproject.toml" - E.g. the following pyproject.toml file: - [[tool.mypy.overrides]] +def get_mypy_toml_data(config_file: str, toml_data: dict[str, Any]) -> dict[str, Any] | None: + if is_pyproject(config_file): + toml_data = toml_data.get("tool", {}) + if "mypy" not in toml_data: + return None + return {"mypy": toml_data["mypy"]} + + if "mypy" in toml_data: + return toml_data + + return {"mypy": toml_data} + + +def _toml_module_error(config_file: str, message: str) -> str: + if is_pyproject(config_file): + return message.format(overrides="tool.mypy.overrides", override="[[tool.mypy.overrides]]") + return message.format(overrides="overrides", override="[[overrides]]") + + +def destructure_overrides(toml_data: dict[str, Any], config_file: str) -> dict[str, Any]: + """Convert TOML overrides sections into the flatter ini-style structure. + + ``pyproject.toml`` uses ``[[tool.mypy.overrides]]``. + ``mypy.toml`` and ``.mypy.toml`` use ``[[overrides]]``. + + E.g. the following TOML file: + + [[overrides]] module = [ "a.b", "b.*" ] disallow_untyped_defs = true - [[tool.mypy.overrides]] + [[overrides]] module = 'c' disallow_untyped_defs = false @@ -434,16 +457,22 @@ def destructure_overrides(toml_data: dict[str, Any]) -> dict[str, Any]: if not isinstance(toml_data["mypy"]["overrides"], list): raise ConfigTOMLValueError( - "tool.mypy.overrides sections must be an array. Please make " - "sure you are using double brackets like so: [[tool.mypy.overrides]]" + _toml_module_error( + config_file, + "{overrides} sections must be an array. Please make sure you are " + "using double brackets like so: {override}", + ) ) result = toml_data.copy() for override in result["mypy"]["overrides"]: if "module" not in override: raise ConfigTOMLValueError( - "toml config file contains a [[tool.mypy.overrides]] " - "section, but no module to override was specified." + _toml_module_error( + config_file, + "toml config file contains a {override} section, but no module to " + "override was specified.", + ) ) if isinstance(override["module"], str): @@ -452,9 +481,11 @@ def destructure_overrides(toml_data: dict[str, Any]) -> dict[str, Any]: modules = override["module"] else: raise ConfigTOMLValueError( - "toml config file contains a [[tool.mypy.overrides]] " - "section with a module value that is not a string or a list of " - "strings" + _toml_module_error( + config_file, + "toml config file contains a {override} section with a module value " + "that is not a string or a list of strings", + ) ) for module in modules: @@ -470,9 +501,12 @@ def destructure_overrides(toml_data: dict[str, Any]) -> dict[str, Any]: and result[old_config_name][new_key] != new_value ): raise ConfigTOMLValueError( - "toml config file contains " - "[[tool.mypy.overrides]] sections with conflicting " - f"values. Module '{module}' has two different values for '{new_key}'" + _toml_module_error( + config_file, + "toml config file contains {override} sections with " + f"conflicting values. Module '{module}' has " + f"two different values for '{new_key}'", + ) ) result[old_config_name][new_key] = new_value diff --git a/mypy/defaults.py b/mypy/defaults.py index a39a577d03ac..35ef2c9b442e 100644 --- a/mypy/defaults.py +++ b/mypy/defaults.py @@ -14,7 +14,7 @@ CACHE_DIR: Final = ".mypy_cache" -CONFIG_NAMES: Final = ["mypy.ini", ".mypy.ini"] +CONFIG_NAMES: Final = ["mypy.ini", ".mypy.ini", "mypy.toml", ".mypy.toml"] SHARED_CONFIG_NAMES: Final = ["pyproject.toml", "setup.cfg"] USER_CONFIG_FILES: list[str] = ["~/.config/mypy/config", "~/.mypy.ini"] diff --git a/mypy/test/test_config_parser.py b/mypy/test/test_config_parser.py index 597143738f23..7f934f65dc47 100644 --- a/mypy/test/test_config_parser.py +++ b/mypy/test/test_config_parser.py @@ -25,7 +25,10 @@ def chdir(target: Path) -> Iterator[None]: def write_config(path: Path, content: str | None = None) -> None: if path.suffix == ".toml": if content is None: - content = "[tool.mypy]\nstrict = true" + if path.name == "pyproject.toml": + content = "[tool.mypy]\nstrict = true" + else: + content = "strict = true" path.write_text(content) else: if content is None: @@ -82,6 +85,8 @@ def test_precedence(self) -> None: setup_cfg = tmpdir / "setup.cfg" mypy_ini = tmpdir / "mypy.ini" dot_mypy = tmpdir / ".mypy.ini" + mypy_toml = tmpdir / "mypy.toml" + dot_mypy_toml = tmpdir / ".mypy.toml" child = tmpdir / "child" child.mkdir() @@ -91,6 +96,8 @@ def test_precedence(self) -> None: write_config(setup_cfg) write_config(mypy_ini) write_config(dot_mypy) + write_config(mypy_toml) + write_config(dot_mypy_toml) with chdir(cwd): result = _find_config_file() @@ -105,6 +112,16 @@ def test_precedence(self) -> None: dot_mypy.unlink() result = _find_config_file() assert result is not None + assert os.path.basename(result[2]) == "mypy.toml" + + mypy_toml.unlink() + result = _find_config_file() + assert result is not None + assert os.path.basename(result[2]) == ".mypy.toml" + + dot_mypy_toml.unlink() + result = _find_config_file() + assert result is not None assert os.path.basename(result[2]) == "pyproject.toml" pyproject.unlink() diff --git a/mypy/test/testcmdline.py b/mypy/test/testcmdline.py index 450f5abc14c3..5f5dadf58b87 100644 --- a/mypy/test/testcmdline.py +++ b/mypy/test/testcmdline.py @@ -35,7 +35,13 @@ python3_path = sys.executable # Files containing test case descriptions. -cmdline_files = ["cmdline.test", "cmdline.pyproject.test", "reports.test", "envvars.test"] +cmdline_files = [ + "cmdline.test", + "cmdline.pyproject.test", + "cmdline.mypy_toml.test", + "reports.test", + "envvars.test", +] class PythonCmdlineSuite(DataSuite): diff --git a/test-data/unit/cmdline.mypy_toml.test b/test-data/unit/cmdline.mypy_toml.test new file mode 100644 index 000000000000..ce1e6fa773ad --- /dev/null +++ b/test-data/unit/cmdline.mypy_toml.test @@ -0,0 +1,198 @@ +-- Tests for mypy.toml / .mypy.toml parsing +-- ---------------------------------------- + +[case testNonArrayOverridesMypyTOML] +# cmd: mypy x.py +[file mypy.toml] +\[overrides] +module = "x" +disallow_untyped_defs = false +[file x.py] +def f(a): + pass +def g(a: int) -> int: + return f(a) +[out] +mypy.toml: overrides sections must be an array. Please make sure you are using double brackets like so: [[overrides]] +== Return code: 0 + +[case testNoModuleInOverrideMypyTOML] +# cmd: mypy x.py +[file mypy.toml] +\[[overrides]] +disallow_untyped_defs = false +[file x.py] +def f(a): + pass +def g(a: int) -> int: + return f(a) +[out] +mypy.toml: toml config file contains a [[overrides]] section, but no module to override was specified. +== Return code: 0 + +[case testInvalidModuleInOverrideMypyTOML] +# cmd: mypy x.py +[file mypy.toml] +\[[overrides]] +module = 0 +disallow_untyped_defs = false +[file x.py] +def f(a): + pass +def g(a: int) -> int: + return f(a) +[out] +mypy.toml: toml config file contains a [[overrides]] section with a module value that is not a string or a list of strings +== Return code: 0 + +[case testConflictingModuleInOverridesMypyTOML] +# cmd: mypy x.py +[file mypy.toml] +\[[overrides]] +module = 'x' +disallow_untyped_defs = false +\[[overrides]] +module = ['x'] +disallow_untyped_defs = true +[file x.py] +def f(a): + pass +def g(a: int) -> int: + return f(a) +[out] +mypy.toml: toml config file contains [[overrides]] sections with conflicting values. Module 'x' has two different values for 'disallow_untyped_defs' +== Return code: 0 + +[case testMultilineLiteralExcludeMypyTOML] +# cmd: mypy x +[file mypy.toml] +exclude = '''(?x)( + (^|/)[^/]*skipme_\.py$ + |(^|/)_skipme[^/]*\.py$ +)''' +[file x/__init__.py] +i: int = 0 +[file x/_skipme_please.py] +This isn't even syntactically valid! +[file x/please_skipme_.py] +Neither is this! + +[case testMultilineBasicExcludeMypyTOML] +# cmd: mypy x +[file mypy.toml] +exclude = """(?x)( + (^|/)[^/]*skipme_\\.py$ + |(^|/)_skipme[^/]*\\.py$ +)""" +[file x/__init__.py] +i: int = 0 +[file x/_skipme_please.py] +This isn't even syntactically valid! +[file x/please_skipme_.py] +Neither is this! + +[case testSequenceExcludeMypyTOML] +# cmd: mypy x +[file mypy.toml] +exclude = [ + '(^|/)[^/]*skipme_\.py$', # literal (no escaping) + "(^|/)_skipme[^/]*\\.py$", # basic (backslash needs escaping) +] +[file x/__init__.py] +i: int = 0 +[file x/_skipme_please.py] +This isn't even syntactically valid! +[file x/please_skipme_.py] +Neither is this! + +[case testMypyTOMLFilesTrailingComma] +# cmd: mypy +[file mypy.toml] +# We combine multiple tests in a single one here, because these tests are slow. +files = """ + a.py, + b.py, +""" +always_true = """ + FLAG_A1, + FLAG_B1, +""" +always_false = """ + FLAG_A2, + FLAG_B2, +""" +[file a.py] +x: str = 'x' # ok' + +# --always-true +FLAG_A1 = False +FLAG_B1 = False +if not FLAG_A1: # unreachable + x: int = 'x' +if not FLAG_B1: # unreachable + y: int = 'y' +[file b.py] +y: int = 'y' # E: Incompatible types in assignment (expression has type "str", variable has type "int") +[file c.py] +# This should not trigger any errors, because it is not included: +z: int = 'z' +[out] + +[case testMypyTOMLModulesTrailingComma] +# cmd: mypy +[file mypy.toml] +# We combine multiple tests in a single one here, because these tests are slow. +modules = """ + a, + b, +""" +disable_error_code = """ + operator, + import, +""" +enable_error_code = """ + redundant-expr, + ignore-without-code, +""" +[file a.py] +x: str = 'x' # ok + +# --enable-error-code +a: int = 'a' # type: ignore + +# --disable-error-code +'a' + 1 +[file b.py] +y: int = 'y' +[file c.py] +# This should not trigger any errors, because it is not included: +z: int = 'z' +[out] +b.py:1: error: Incompatible types in assignment (expression has type "str", variable has type "int") +a.py:4: error: "type: ignore" comment without error code (consider "type: ignore[assignment]" instead) + +[case testMypyTOMLPackagesTrailingComma] +# cmd: mypy +[file mypy.toml] +packages = """ + a, + b, +""" +[file a/__init__.py] +x: str = 'x' # ok +[file b/__init__.py] +y: int = 'y' # E: Incompatible types in assignment (expression has type "str", variable has type "int") +[file c/__init__.py] +# This should not trigger any errors, because it is not included: +z: int = 'z' +[out] + +[case testMypyTOMLSettingOfWrongType] +# cmd: mypy a.py +[file mypy.toml] +enable_error_code = true +[file a.py] +x: int = 1 +[out] +mypy.toml: [mypy]: enable_error_code: Expected a list or a stringified version thereof, but got: 'True', of type bool. +== Return code: 0