diff --git a/CHANGES.rst b/CHANGES.rst index fffde3f5f..a02fe5b1a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -23,6 +23,12 @@ Unreleased - The HTML and textual reports now have a ``--skip-empty`` option that skips files with no statements (notably, ``__init__.py`` files). Thanks, Reya B. +- Configuration can now be read from `TOML`_ files. This requires installing + coverage.py with the ``[toml]`` extra. The standard "pyproject.toml" file + will be read automatically if no other configuration file is found, with + settings in the ``[tool.coverage.]`` namespace. Thanks to Frazer McLean for + implementation and persistence. Finishes `issue 664`_. + - The HTML report has been reimplemented (no more table around the source code). This allowed for a better presentation of the context information, hopefully resolving `issue 855`_. @@ -33,6 +39,8 @@ Unreleased ``coverage html --show-contexts``) will issue a warning if there were no contexts measured (`issue 851`_). +.. _TOML: https://github.com/toml-lang/toml#toml +.. _issue 664: https://github.com/nedbat/coveragepy/issues/664 .. _issue 851: https://github.com/nedbat/coveragepy/issues/851 .. _issue 855: https://github.com/nedbat/coveragepy/issues/855 @@ -79,7 +87,6 @@ Version 5.0a7 --- 2019-09-21 - ``debug=plugin`` didn't properly support configuration or dynamic context plugins, but now it does, closing `issue 834`_. - .. _issue 720: https://github.com/nedbat/coveragepy/issues/720 .. _issue 822: https://github.com/nedbat/coveragepy/issues/822 .. _issue 834: https://github.com/nedbat/coveragepy/issues/834 diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index c54f0c6e4..b48862052 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -51,6 +51,7 @@ Eduardo Schettino Emil Madsen Edward Loper Federico Bond +Frazer McLean Geoff Bache George Paci George Song diff --git a/coverage/backward.py b/coverage/backward.py index 587595453..17f04219d 100644 --- a/coverage/backward.py +++ b/coverage/backward.py @@ -3,9 +3,10 @@ """Add things to old Pythons so I can pretend they are newer.""" -# This file does tricky stuff, so disable a pylint warning. +# This file's purpose is to provide modules to be imported from here. # pylint: disable=unused-import +import os import sys from coverage import env @@ -55,6 +56,15 @@ except ImportError: from threading import get_ident as get_thread_id +try: + os.PathLike +except AttributeError: + # This is Python 2 and 3 + path_types = (bytes, string_class, unicode_class) +else: + # 3.6+ + path_types = (bytes, str, os.PathLike) + # shlex.quote is new, but there's an undocumented implementation in "pipes", # who knew!? try: diff --git a/coverage/cmdline.py b/coverage/cmdline.py index ef1184d09..448dd470b 100644 --- a/coverage/cmdline.py +++ b/coverage/cmdline.py @@ -150,8 +150,8 @@ class Opts(object): '', '--rcfile', action='store', help=( "Specify configuration file. " - "By default '.coveragerc', 'setup.cfg' and 'tox.ini' are tried. " - "[env: COVERAGE_RCFILE]" + "By default '.coveragerc', 'pyproject.toml', 'setup.cfg' and " + "'tox.ini' are tried. [env: COVERAGE_RCFILE]" ), ) source = optparse.make_option( diff --git a/coverage/config.py b/coverage/config.py index c6689d2dd..62f281add 100644 --- a/coverage/config.py +++ b/coverage/config.py @@ -13,6 +13,8 @@ from coverage.misc import contract, CoverageException, isolate_module from coverage.misc import substitute_variables +from coverage.tomlconfig import TomlConfigParser, TomlDecodeError + os = isolate_module(os) @@ -256,12 +258,17 @@ def from_file(self, filename, our_file): coverage.py settings in it. """ + _, ext = os.path.splitext(filename) + if ext == '.toml': + cp = TomlConfigParser(our_file) + else: + cp = HandyConfigParser(our_file) + self.attempted_config_files.append(filename) - cp = HandyConfigParser(our_file) try: files_read = cp.read(filename) - except configparser.Error as err: + except (configparser.Error, TomlDecodeError) as err: raise CoverageException("Couldn't read config file %s: %s" % (filename, err)) if not files_read: return False @@ -475,6 +482,7 @@ def config_files_to_try(config_file): (config_file, True, specified_file), ("setup.cfg", False, False), ("tox.ini", False, False), + ("pyproject.toml", False, False), ] return files_to_try diff --git a/coverage/optional.py b/coverage/optional.py new file mode 100644 index 000000000..ee617b625 --- /dev/null +++ b/coverage/optional.py @@ -0,0 +1,68 @@ +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt + +""" +Imports that we need at runtime, but might not be present. + +When importing one of these modules, always do it in the function where you +need the module. Some tests will need to remove the module. If you import +it at the top level of your module, then the test won't be able to simulate +the module being unimportable. + +The import will always succeed, but the value will be None if the module is +unavailable. + +Bad:: + + # MyModule.py + from coverage.optional import unsure + + def use_unsure(): + unsure.something() + +Good:: + + # MyModule.py + + def use_unsure(): + from coverage.optional import unsure + if unsure is None: + raise Exception("Module unsure isn't available!") + + unsure.something() + +""" + +import contextlib + +# This file's purpose is to provide modules to be imported from here. +# pylint: disable=unused-import + +# TOML support is an install-time extra option. +try: + import toml +except ImportError: # pragma: not covered + toml = None + + +@contextlib.contextmanager +def without(modname): + """Hide a module for testing. + + Use this in a test function to make an optional module unavailable during + the test:: + + with coverage.optional.without('toml'): + use_toml_somehow() + + Arguments: + modname (str): the name of a module importable from + `coverage.optional`. + + """ + real_module = globals()[modname] + try: + globals()[modname] = None + yield + finally: + globals()[modname] = real_module diff --git a/coverage/tomlconfig.py b/coverage/tomlconfig.py new file mode 100644 index 000000000..f5978820c --- /dev/null +++ b/coverage/tomlconfig.py @@ -0,0 +1,169 @@ +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt + +"""TOML configuration support for coverage.py""" + +import io +import os +import re + +from coverage import env +from coverage.backward import configparser, path_types +from coverage.misc import CoverageException, substitute_variables + + +class TomlDecodeError(Exception): + """An exception class that exists even when toml isn't installed.""" + pass + + +class TomlConfigParser: + """TOML file reading with the interface of HandyConfigParser.""" + + # This class has the same interface as config.HandyConfigParser, no + # need for docstrings. + # pylint: disable=missing-function-docstring + + def __init__(self, our_file): + self.our_file = our_file + self.getters = [lambda obj: obj['tool']['coverage']] + if self.our_file: + self.getters.append(lambda obj: obj) + + self._data = [] + + def read(self, filenames): + # RawConfigParser takes a filename or list of filenames, but we only + # ever call this with a single filename. + assert isinstance(filenames, path_types) + filename = filenames + if env.PYVERSION >= (3, 6): + filename = os.fspath(filename) + + from coverage.optional import toml + if toml is None: + if self.our_file: + raise CoverageException("Can't read {!r} without TOML support".format(filename)) + + try: + with io.open(filename, encoding='utf-8') as fp: + toml_data = fp.read() + toml_data = substitute_variables(toml_data, os.environ) + if toml: + try: + self._data.append(toml.loads(toml_data)) + except toml.TomlDecodeError as err: + raise TomlDecodeError(*err.args) + elif re.search(r"^\[tool\.coverage\.", toml_data, flags=re.MULTILINE): + # Looks like they meant to read TOML, but we can't. + raise CoverageException("Can't read {!r} without TOML support".format(filename)) + else: + return [] + except IOError: + return [] + return [filename] + + def has_option(self, section, option): + for data in self._data: + for getter in self.getters: + try: + getter(data)[section][option] + except KeyError: + continue + return True + return False + + def has_section(self, section): + for data in self._data: + for getter in self.getters: + try: + getter(data)[section] + except KeyError: + continue + return section + return False + + def options(self, section): + for data in self._data: + for getter in self.getters: + try: + section = getter(data)[section] + except KeyError: + continue + return list(section.keys()) + raise configparser.NoSectionError(section) + + def get_section(self, section): + d = {} + for opt in self.options(section): + d[opt] = self.get(section, opt) + return d + + def get(self, section, option): + found_section = False + for data in self._data: + for getter in self.getters: + try: + section = getter(data)[section] + except KeyError: + continue + + found_section = True + try: + value = section[option] + except KeyError: + continue + return value + if not found_section: + raise configparser.NoSectionError(section) + raise configparser.NoOptionError(option, section) + + def getboolean(self, section, option): + value = self.get(section, option) + if not isinstance(value, bool): + raise ValueError( + 'Option {!r} in section {!r} is not a boolean: {!r}' + .format(option, section, value) + ) + return value + + def getlist(self, section, option): + values = self.get(section, option) + if not isinstance(values, list): + raise ValueError( + 'Option {!r} in section {!r} is not a list: {!r}' + .format(option, section, values) + ) + return values + + def getregexlist(self, section, option): + values = self.getlist(section, option) + for value in values: + value = value.strip() + try: + re.compile(value) + except re.error as e: + raise CoverageException( + "Invalid [%s].%s value %r: %s" % (section, option, value, e) + ) + return values + + def getint(self, section, option): + value = self.get(section, option) + if not isinstance(value, int): + raise ValueError( + 'Option {!r} in section {!r} is not an integer: {!r}' + .format(option, section, value) + ) + return value + + def getfloat(self, section, option): + value = self.get(section, option) + if isinstance(value, int): + value = float(value) + if not isinstance(value, float): + raise ValueError( + 'Option {!r} in section {!r} is not a float: {!r}' + .format(option, section, value) + ) + return value diff --git a/doc/config.rst b/doc/config.rst index d7623532d..f7483d064 100644 --- a/doc/config.rst +++ b/doc/config.rst @@ -29,7 +29,10 @@ Coverage.py will read settings from other usual configuration files if no other configuration file is used. It will automatically read from "setup.cfg" or "tox.ini" if they exist. In this case, the section names have "coverage:" prefixed, so the ``[run]`` options described below will be found in the -``[coverage:run]`` section of the file. +``[coverage:run]`` section of the file. If coverage.py is installed with the +``toml`` extra (``pip install coverage[toml]``), it will automatically read +from "pyproject.toml". Configuration must be within the ``[tool.coverage]`` +section, for example, ``[tool.coverage.run]``. Syntax diff --git a/setup.py b/setup.py index 09d4eb170..109737405 100644 --- a/setup.py +++ b/setup.py @@ -93,6 +93,11 @@ ], }, + extras_require={ + # Enable pyproject.toml support. + 'toml': ['toml'], + }, + # We need to get HTML assets from our htmlfiles directory. zip_safe=False, diff --git a/tests/test_config.py b/tests/test_config.py index fcbac816a..74ff5f009 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,6 +8,7 @@ import coverage from coverage.misc import CoverageException +import coverage.optional from tests.coveragetest import CoverageTest, UsingModulesMixin @@ -56,6 +57,36 @@ def test_named_config_file(self): self.assertFalse(cov.config.branch) self.assertEqual(cov.config.data_file, "delete.me") + def test_toml_config_file(self): + # A .coveragerc file will be read into the configuration. + self.make_file("pyproject.toml", """\ + # This is just a bogus toml file for testing. + [tool.coverage.run] + concurrency = ["a", "b"] + timid = true + data_file = ".hello_kitty.data" + [tool.coverage.report] + precision = 3 + fail_under = 90.5 + """) + cov = coverage.Coverage(config_file="pyproject.toml") + self.assertTrue(cov.config.timid) + self.assertFalse(cov.config.branch) + self.assertEqual(cov.config.concurrency, ["a", "b"]) + self.assertEqual(cov.config.data_file, ".hello_kitty.data") + self.assertEqual(cov.config.precision, 3) + self.assertAlmostEqual(cov.config.fail_under, 90.5) + + # Test that our class doesn't reject integers when loading floats + self.make_file("pyproject.toml", """\ + # This is just a bogus toml file for testing. + [tool.coverage.report] + fail_under = 90 + """) + cov = coverage.Coverage(config_file="pyproject.toml") + self.assertAlmostEqual(cov.config.fail_under, 90) + self.assertIsInstance(cov.config.fail_under, float) + def test_ignored_config_file(self): # You can disable reading the .coveragerc file. self.make_file(".coveragerc", """\ @@ -142,6 +173,33 @@ def test_parse_errors(self): with self.assertRaisesRegex(CoverageException, msg): coverage.Coverage() + def test_toml_parse_errors(self): + # Im-parsable values raise CoverageException, with details. + bad_configs_and_msgs = [ + ("[tool.coverage.run]\ntimid = \"maybe?\"\n", r"maybe[?]"), + # ("timid = 1\n", r"timid = 1"), + ("[tool.coverage.run\n", r"Key group"), + ('[tool.coverage.report]\nexclude_lines = ["foo("]\n', + r"Invalid \[report\].exclude_lines value u?'foo\(': " + r"(unbalanced parenthesis|missing \))"), + ('[tool.coverage.report]\npartial_branches = ["foo["]\n', + r"Invalid \[report\].partial_branches value u?'foo\[': " + r"(unexpected end of regular expression|unterminated character set)"), + ('[tool.coverage.report]\npartial_branches_always = ["foo***"]\n', + r"Invalid \[report\].partial_branches_always value " + r"u?'foo\*\*\*': " + r"multiple repeat"), + ('[tool.coverage.run]\nconcurrency="foo"', "not a list"), + ("[tool.coverage.report]\nprecision=1.23", "not an integer"), + ('[tool.coverage.report]\nfail_under="s"', "not a float"), + ] + + for bad_config, msg in bad_configs_and_msgs: + print("Trying %r" % bad_config) + self.make_file("pyproject.toml", bad_config) + with self.assertRaisesRegex(CoverageException, msg): + coverage.Coverage() + def test_environment_vars_in_config(self): # Config files can have $envvars in them. self.make_file(".coveragerc", """\ @@ -167,6 +225,32 @@ def test_environment_vars_in_config(self): ["the_$one", "anotherZZZ", "xZZZy", "xy", "huh${X}what"] ) + def test_environment_vars_in_toml_config(self): + # Config files can have $envvars in them. + self.make_file("pyproject.toml", """\ + [tool.coverage.run] + data_file = "$DATA_FILE.fooey" + branch = $BRANCH + [tool.coverage.report] + exclude_lines = [ + "the_$$one", + "another${THING}", + "x${THING}y", + "x${NOTHING}y", + "huh$${X}what", + ] + """) + self.set_environ("BRANCH", "true") + self.set_environ("DATA_FILE", "hello-world") + self.set_environ("THING", "ZZZ") + cov = coverage.Coverage() + self.assertEqual(cov.config.data_file, "hello-world.fooey") + self.assertEqual(cov.config.branch, True) + self.assertEqual( + cov.config.exclude_list, + ["the_$one", "anotherZZZ", "xZZZy", "xy", "huh${X}what"] + ) + def test_tilde_in_config(self): # Config entries that are file paths can be tilde-expanded. self.make_file(".coveragerc", """\ @@ -198,6 +282,38 @@ def expanduser(s): self.assertEqual(cov.config.xml_output, "/Users/me/somewhere/xml.out") self.assertEqual(cov.config.exclude_list, ["~/data.file", "~joe/html_dir"]) + def test_tilde_in_toml_config(self): + # Config entries that are file paths can be tilde-expanded. + self.make_file("pyproject.toml", """\ + [tool.coverage.run] + data_file = "~/data.file" + + [tool.coverage.html] + directory = "~joe/html_dir" + + [tool.coverage.xml] + output = "~/somewhere/xml.out" + + [tool.coverage.report] + # Strings that aren't file paths are not tilde-expanded. + exclude_lines = [ + "~/data.file", + "~joe/html_dir", + ] + """) + def expanduser(s): + """Fake tilde expansion""" + s = s.replace("~/", "/Users/me/") + s = s.replace("~joe/", "/Users/joe/") + return s + + with mock.patch.object(coverage.config.os.path, 'expanduser', new=expanduser): + cov = coverage.Coverage() + self.assertEqual(cov.config.data_file, "/Users/me/data.file") + self.assertEqual(cov.config.html_dir, "/Users/joe/html_dir") + self.assertEqual(cov.config.xml_output, "/Users/me/somewhere/xml.out") + self.assertEqual(cov.config.exclude_list, ["~/data.file", "~joe/html_dir"]) + def test_tweaks_after_constructor(self): # set_option can be used after construction to affect the config. cov = coverage.Coverage(timid=True, data_file="fooey.dat") @@ -246,6 +362,15 @@ def test_unknown_option(self): with self.assertRaisesRegex(CoverageException, msg): _ = coverage.Coverage() + def test_unknown_option_toml(self): + self.make_file("pyproject.toml", """\ + [tool.coverage.run] + xyzzy = 17 + """) + msg = r"Unrecognized option '\[run\] xyzzy=' in config file pyproject.toml" + with self.assertRaisesRegex(CoverageException, msg): + _ = coverage.Coverage() + def test_misplaced_option(self): self.make_file(".coveragerc", """\ [report] @@ -527,3 +652,36 @@ def test_nocoveragerc_file_when_specified(self): self.assertFalse(cov.config.timid) self.assertFalse(cov.config.branch) self.assertEqual(cov.config.data_file, ".coverage") + + def test_no_toml_installed_explicit_toml(self): + # Can't specify a toml config file if toml isn't installed. + with coverage.optional.without('toml'): + msg = "Can't read 'cov.toml' without TOML support" + with self.assertRaisesRegex(CoverageException, msg): + coverage.Coverage(config_file="cov.toml") + + def test_no_toml_installed_pyproject_toml(self): + # Can't have coverage config in pyproject.toml without toml installed. + self.make_file("pyproject.toml", """\ + # A toml file! + [tool.coverage.run] + xyzzy = 17 + """) + with coverage.optional.without('toml'): + msg = "Can't read 'pyproject.toml' without TOML support" + with self.assertRaisesRegex(CoverageException, msg): + coverage.Coverage() + + def test_no_toml_installed_pyproject_no_coverage(self): + # It's ok to have non-coverage pyproject.toml without toml installed. + self.make_file("pyproject.toml", """\ + # A toml file! + [tool.something] + xyzzy = 17 + """) + with coverage.optional.without('toml'): + cov = coverage.Coverage() + # We get default settings: + self.assertFalse(cov.config.timid) + self.assertFalse(cov.config.branch) + self.assertEqual(cov.config.data_file, ".coverage") diff --git a/tests/test_testing.py b/tests/test_testing.py index 4a4a013cb..15e6f84f4 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -15,6 +15,7 @@ from coverage.backunittest import TestCase, unittest from coverage.files import actual_path from coverage.misc import StopEverything +import coverage.optional from tests.coveragetest import CoverageTest, convert_skip_exceptions from tests.helpers import CheckUniqueFilenames, re_lines, re_line @@ -308,3 +309,14 @@ def _same_python_executable(e1, e2): return True return False # pragma: only failure + + +def test_optional_without(): + # pylint: disable=reimported + from coverage.optional import toml as toml1 + with coverage.optional.without('toml'): + from coverage.optional import toml as toml2 + from coverage.optional import toml as toml3 + + assert toml1 is toml3 is not None + assert toml2 is None diff --git a/tox.ini b/tox.ini index f4ebc0225..935c88ddc 100644 --- a/tox.ini +++ b/tox.ini @@ -8,6 +8,8 @@ toxworkdir = {env:TOXWORKDIR:.tox} [testenv] usedevelop = True +extras = + toml deps = # Check here for what might be out of date: