diff options
-rw-r--r-- | CHANGES.rst | 3 | ||||
-rw-r--r-- | CONTRIBUTORS.txt | 1 | ||||
-rw-r--r-- | coverage/backward.py | 15 | ||||
-rw-r--r-- | coverage/cmdline.py | 4 | ||||
-rw-r--r-- | coverage/config.py | 16 | ||||
-rw-r--r-- | coverage/tomlconfig.py | 146 | ||||
-rw-r--r-- | doc/config.rst | 5 | ||||
-rw-r--r-- | setup.py | 5 | ||||
-rw-r--r-- | tests/test_config.py | 123 | ||||
-rw-r--r-- | tox.ini | 1 |
10 files changed, 313 insertions, 6 deletions
diff --git a/CHANGES.rst b/CHANGES.rst index fffde3f5..595c02b3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -80,11 +80,14 @@ Version 5.0a7 --- 2019-09-21 plugins, but now it does, closing `issue 834`_. +- Added TOML configuration support, including pyproject.toml `issue 664`_. + .. _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 .. _issue 829: https://github.com/nedbat/coveragepy/issues/829 .. _issue 846: https://github.com/nedbat/coveragepy/issues/846 +.. _issue 664: https://github.com/nedbat/coveragepy/issues/664 .. _changes_50a6: diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index c54f0c6e..b4886205 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 58759545..e051fa55 100644 --- a/coverage/backward.py +++ b/coverage/backward.py @@ -6,6 +6,7 @@ # This file does tricky stuff, so disable a pylint warning. # pylint: disable=unused-import +import os import sys from coverage import env @@ -26,6 +27,11 @@ try: except ImportError: import configparser +try: + import toml +except ImportError: + toml = None + # What's a string called? try: string_class = basestring @@ -55,6 +61,15 @@ try: 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 ef1184d0..448dd470 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 c6689d2d..89a0321e 100644 --- a/coverage/config.py +++ b/coverage/config.py @@ -9,10 +9,12 @@ import os import re from coverage import env -from coverage.backward import configparser, iitems, string_class +from coverage.backward import configparser, iitems, string_class, toml 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,19 @@ class CoverageConfig(object): coverage.py settings in it. """ + _, ext = os.path.splitext(filename) + if ext == '.toml': + if toml is None: + return False + 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 @@ -473,6 +482,7 @@ def config_files_to_try(config_file): config_file = ".coveragerc" files_to_try = [ (config_file, True, specified_file), + ("pyproject.toml", False, False), ("setup.cfg", False, False), ("tox.ini", False, False), ] diff --git a/coverage/tomlconfig.py b/coverage/tomlconfig.py new file mode 100644 index 00000000..0d084603 --- /dev/null +++ b/coverage/tomlconfig.py @@ -0,0 +1,146 @@ +import io +import os +import re + +from coverage import env +from coverage.backward import configparser, path_types, string_class, toml +from coverage.misc import CoverageException, substitute_variables + + +class TomlDecodeError(Exception): + """An exception class that exists even when toml isn't installed.""" + + +class TomlConfigParser: + def __init__(self, our_file): + self.getters = [lambda obj: obj['tool']['coverage']] + if our_file: + self.getters.append(lambda obj: obj) + + self._data = [] + + def read(self, filenames): + if toml is None: + raise RuntimeError('toml module is not installed.') + + if isinstance(filenames, path_types): + filenames = [filenames] + read_ok = [] + for filename in filenames: + try: + with io.open(filename, encoding='utf-8') as fp: + self._data.append(toml.load(fp)) + except IOError: + continue + except toml.TomlDecodeError as err: + raise TomlDecodeError(*err.args) + if env.PYVERSION >= (3, 6): + filename = os.fspath(filename) + read_ok.append(filename) + return read_ok + + 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 + if isinstance(value, string_class): + value = substitute_variables(value, os.environ) + 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)) + for i, value in enumerate(values): + if isinstance(value, string_class): + values[i] = substitute_variables(value, os.environ) + 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 d7623532..b8a3d328 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, e.g. ``[tool.coverage.run]`. Syntax @@ -93,6 +93,11 @@ setup_args = dict( ], }, + 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 fcbac816..49574241 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -56,6 +56,36 @@ class ConfigTest(CoverageTest): 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 +172,33 @@ class ConfigTest(CoverageTest): 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 +224,31 @@ class ConfigTest(CoverageTest): ["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 = true + [tool.coverage.report] + exclude_lines = [ + "the_$$one", + "another${THING}", + "x${THING}y", + "x${NOTHING}y", + "huh$${X}what", + ] + """) + 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 +280,38 @@ class ConfigTest(CoverageTest): 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 +360,15 @@ class ConfigTest(CoverageTest): 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] @@ -15,6 +15,7 @@ deps = -r requirements/pytest.pip pip==19.3.1 setuptools==41.4.0 + toml # gevent 1.3 causes a failure: https://github.com/nedbat/coveragepy/issues/663 py{27,35,36}: gevent==1.2.2 py{27,35,36,37,38}: eventlet==0.25.1 |