diff --git a/CHANGES.rst b/CHANGES.rst index e186939fc..e6111b3fd 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -23,7 +23,12 @@ upgrading your version of coverage.py. Unreleased ---------- -Nothing yet. +- Feature: Added support for ``.coveragerc.toml`` configuration files. This + provides a more flexible approach to manage coverage settings as many + projects have switched to TOML configurations. Closes `issue 1643`_. + +.. _issue 1643: https://github.com/nedbat/coveragepy/issues/1643 +.. _pull 1952: https://github.com/nedbat/coveragepy/pull/1952 .. start-releases diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index bd83cfb3f..fcb419dc3 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -185,6 +185,7 @@ Nils Kattenbeck Noel O'Boyle Oleg Höfling Oleh Krehel +Olena Yefymenko Olivier Grisel Ori Avtalion Pablo Carballo diff --git a/coverage/config.py b/coverage/config.py index 435ddd5f9..00fa17fac 100644 --- a/coverage/config.py +++ b/coverage/config.py @@ -573,6 +573,7 @@ def config_files_to_try(config_file: bool | str) -> list[tuple[str, bool, bool]] assert isinstance(config_file, str) files_to_try = [ (config_file, True, specified_file), + (".coveragerc.toml", True, False), ("setup.cfg", False, False), ("tox.ini", False, False), ("pyproject.toml", False, False), diff --git a/doc/config.rst b/doc/config.rst index 5d2eab092..96f664fc9 100644 --- a/doc/config.rst +++ b/doc/config.rst @@ -39,12 +39,13 @@ environment variable. If ``.coveragerc`` doesn't exist and another file hasn't been specified, then coverage.py will look for settings in other common configuration files, in this -order: setup.cfg, tox.ini, or pyproject.toml. The first file found with +order: .coveragerc.toml, setup.cfg, tox.ini, or pyproject.toml. The first file found with coverage.py settings will be used and other files won't be consulted. -Coverage.py will read from "pyproject.toml" if TOML support is available, +Coverage.py will read from ".coveragerc.toml" and "pyproject.toml" if TOML support is available, either because you are running on Python 3.11 or later, or because you -installed with the ``toml`` extra (``pip install coverage[toml]``). +installed with the ``toml`` extra (``pip install coverage[toml]``). Both files +use the same ``[tool.coverage]`` section structure. Syntax @@ -136,6 +137,35 @@ Here's a sample configuration file, in each syntax: [html] directory = coverage_html_report """, + + coveragerc_toml=r""" + [tool.coverage.run] + branch = true + + [tool.coverage.report] + # Regexes for lines to exclude from consideration + exclude_also = [ + # Don't complain about missing debug-only code: + "def __repr__", + "if self\\.debug", + + # Don't complain if tests don't hit defensive assertion code: + "raise AssertionError", + "raise NotImplementedError", + + # Don't complain if non-runnable code isn't run: + "if 0:", + "if __name__ == .__main__.:", + + # Don't complain about abstract methods, they aren't run: + "@(abc\\.)?abstractmethod", + ] + + ignore_errors = true + + [tool.coverage.html] + directory = "coverage_html_report" + """, toml=r""" [tool.coverage.run] branch = true @@ -198,6 +228,36 @@ Here's a sample configuration file, in each syntax: [html] directory = coverage_html_report + .. code-tab:: toml + :caption: .coveragerc.toml + + [tool.coverage.run] + branch = true + + [tool.coverage.report] + # Regexes for lines to exclude from consideration + exclude_also = [ + # Don't complain about missing debug-only code: + "def __repr__", + "if self\\.debug", + + # Don't complain if tests don't hit defensive assertion code: + "raise AssertionError", + "raise NotImplementedError", + + # Don't complain if non-runnable code isn't run: + "if 0:", + "if __name__ == .__main__.:", + + # Don't complain about abstract methods, they aren't run: + "@(abc\\.)?abstractmethod", + ] + + ignore_errors = true + + [tool.coverage.html] + directory = "coverage_html_report" + .. code-tab:: toml :caption: pyproject.toml diff --git a/tests/test_config.py b/tests/test_config.py index f1631a7fe..ba05c7f42 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -66,9 +66,10 @@ def test_named_config_file(self, file_class: FilePathType) -> None: assert not cov.config.branch assert cov.config.data_file == "delete.me" - def test_toml_config_file(self) -> None: - # A pyproject.toml file will be read into the configuration. - self.make_file("pyproject.toml", """\ + @pytest.mark.parametrize("filename", ["pyproject.toml", ".coveragerc.toml"]) + def test_toml_config_file(self, filename: str) -> None: + # A pyproject.toml and coveragerc.toml will be read into the configuration. + self.make_file(filename, """\ # This is just a bogus toml file for testing. [tool.somethingelse] authors = ["Joe D'Ávila "] @@ -96,9 +97,10 @@ def test_toml_config_file(self) -> None: assert cov.config.fail_under == 90.5 assert cov.config.get_plugin_options("plugins.a_plugin") == {"hello": "world"} - def test_toml_ints_can_be_floats(self) -> None: + @pytest.mark.parametrize("filename", ["pyproject.toml", ".coveragerc.toml"]) + def test_toml_ints_can_be_floats(self, filename: str) -> None: # Test that our class doesn't reject integers when loading floats - self.make_file("pyproject.toml", """\ + self.make_file(filename, """\ # This is just a bogus toml file for testing. [tool.coverage.report] fail_under = 90 @@ -208,6 +210,7 @@ def test_parse_errors(self, bad_config: str, msg: str) -> None: with pytest.raises(ConfigError, match=msg): coverage.Coverage() + @pytest.mark.parametrize("filename", ["pyproject.toml", ".coveragerc.toml"]) @pytest.mark.parametrize("bad_config, msg", [ ("[tool.coverage.run]\ntimid = \"maybe?\"\n", r"maybe[?]"), ("[tool.coverage.run\n", None), @@ -225,9 +228,9 @@ def test_parse_errors(self, bad_config: str, msg: str) -> None: ("[tool.coverage.report]\nprecision=1.23", "not an integer"), ('[tool.coverage.report]\nfail_under="s"', "couldn't convert to a float"), ]) - def test_toml_parse_errors(self, bad_config: str, msg: str) -> None: + def test_toml_parse_errors(self, filename: str, bad_config: str, msg: str) -> None: # Im-parsable values raise ConfigError, with details. - self.make_file("pyproject.toml", bad_config) + self.make_file(filename, bad_config) with pytest.raises(ConfigError, match=msg): coverage.Coverage() @@ -253,9 +256,10 @@ def test_environment_vars_in_config(self) -> None: assert cov.config.branch is True assert cov.config.exclude_list == ["the_$one", "anotherZZZ", "xZZZy", "xy", "huh${X}what"] - def test_environment_vars_in_toml_config(self) -> None: + @pytest.mark.parametrize("filename", ["pyproject.toml", ".coveragerc.toml"]) + def test_environment_vars_in_toml_config(self, filename: str) -> None: # Config files can have $envvars in them. - self.make_file("pyproject.toml", """\ + self.make_file(filename, """\ [tool.coverage.run] data_file = "$DATA_FILE.fooey" branch = "$BRANCH" @@ -327,9 +331,10 @@ def expanduser(s: str) -> str: assert cov.config.exclude_list == ["~/data.file", "~joe/html_dir"] assert cov.config.paths == {'mapping': ['/Users/me/src', '/Users/joe/source']} - def test_tilde_in_toml_config(self) -> None: + @pytest.mark.parametrize("filename", ["pyproject.toml", ".coveragerc.toml"]) + def test_tilde_in_toml_config(self, filename: str) -> None: # Config entries that are file paths can be tilde-expanded. - self.make_file("pyproject.toml", """\ + self.make_file(filename, """\ [tool.coverage.run] data_file = "~/data.file" @@ -444,21 +449,13 @@ def test_unknown_option(self) -> None: with pytest.warns(CoverageWarning, match=msg): _ = coverage.Coverage() - def test_unknown_option_toml(self) -> None: - self.make_file("pyproject.toml", """\ + @pytest.mark.parametrize("filename", ["pyproject.toml", ".coveragerc.toml"]) + def test_unknown_option_toml(self, filename: str) -> None: + self.make_file(filename, """\ [tool.coverage.run] xyzzy = 17 """) - msg = r"Unrecognized option '\[tool.coverage.run\] xyzzy=' in config file pyproject.toml" - with pytest.warns(CoverageWarning, match=msg): - _ = coverage.Coverage() - - def test_misplaced_option(self) -> None: - self.make_file(".coveragerc", """\ - [report] - branch = True - """) - msg = r"Unrecognized option '\[report\] branch=' in config file .coveragerc" + msg = f"Unrecognized option '\\[tool.coverage.run\\] xyzzy=' in config file {filename}" with pytest.warns(CoverageWarning, match=msg): _ = coverage.Coverage() @@ -483,8 +480,10 @@ def test_exceptions_from_missing_things(self) -> None: with pytest.raises(ConfigError, match="No option 'foo' in section: 'xyzzy'"): config.get("xyzzy", "foo") - def test_exclude_also(self) -> None: - self.make_file("pyproject.toml", """\ + + @pytest.mark.parametrize("filename", ["pyproject.toml", ".coveragerc.toml"]) + def test_exclude_also(self, filename: str) -> None: + self.make_file(filename, """\ [tool.coverage.report] exclude_also = ["foobar", "raise .*Error"] """) @@ -840,16 +839,62 @@ def test_no_toml_installed_pyproject_no_coverage(self) -> None: assert not cov.config.branch assert cov.config.data_file == ".coverage" - def test_exceptions_from_missing_toml_things(self) -> None: - self.make_file("pyproject.toml", """\ + @pytest.mark.parametrize("filename", ["pyproject.toml", ".coveragerc.toml"]) + def test_exceptions_from_missing_toml_things(self, filename: str) -> None: + self.make_file(filename, """\ [tool.coverage.run] branch = true """) config = TomlConfigParser(False) - config.read("pyproject.toml") + config.read(filename) with pytest.raises(ConfigError, match="No section: 'xyzzy'"): config.options("xyzzy") with pytest.raises(ConfigError, match="No section: 'xyzzy'"): config.get("xyzzy", "foo") with pytest.raises(ConfigError, match="No option 'foo' in section: 'tool.coverage.run'"): config.get("run", "foo") + + def test_coveragerc_toml_priority(self) -> None: + """Test that .coveragerc.toml has priority over pyproject.toml.""" + self.make_file(".coveragerc.toml", """\ + [tool.coverage.run] + timid = true + data_file = ".toml-data.dat" + branch = true + """) + + self.make_file("pyproject.toml", """\ + [tool.coverage.run] + timid = false + data_file = "pyproject-data.dat" + branch = false + """) + cov = coverage.Coverage() + + assert cov.config.timid is True + assert cov.config.data_file == ".toml-data.dat" + assert cov.config.branch is True + + + @pytest.mark.skipif(env.PYVERSION >= (3, 11), reason="Python 3.11 has toml in stdlib") + def test_toml_file_exists_but_no_toml_support(self) -> None: + """Test behavior when .coveragerc.toml exists but TOML support is missing.""" + self.make_file(".coveragerc.toml", """\ + [tool.coverage.run] + timid = true + data_file = ".toml-data.dat" + """) + + with mock.patch.object(coverage.tomlconfig, "has_tomllib", False): + msg = "Can't read '.coveragerc.toml' without TOML support" + with pytest.raises(ConfigError, match=msg): + coverage.Coverage(config_file=".coveragerc.toml") + self.make_file(".coveragerc", """\ + [run] + timid = false + data_file = .ini-data.dat + """) + + cov = coverage.Coverage() + assert not cov.config.timid + assert cov.config.data_file == ".ini-data.dat"