From 01313033639156a1fcb37223e442d1c291eecea1 Mon Sep 17 00:00:00 2001 From: Frazer McLean Date: Mon, 16 Sep 2019 22:24:53 +0200 Subject: [PATCH 01/13] Initial pyproject.toml support --- coverage/backward.py | 10 ++++ coverage/config.py | 137 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 145 insertions(+), 2 deletions(-) diff --git a/coverage/backward.py b/coverage/backward.py index 587595453..e8ea3379f 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 @@ -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/config.py b/coverage/config.py index 7d6911458..179418506 100644 --- a/coverage/config.py +++ b/coverage/config.py @@ -5,17 +5,142 @@ import collections import copy +import io import os import re from coverage import env -from coverage.backward import configparser, iitems, string_class +from coverage.backward import configparser, iitems, path_types, string_class from coverage.misc import contract, CoverageException, isolate_module from coverage.misc import substitute_variables +try: + import toml +except ImportError: + toml = None + os = isolate_module(os) +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 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, toml.TomlDecodeError): + continue + 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 + class HandyConfigParser(configparser.RawConfigParser): """Our specialization of ConfigParser.""" @@ -255,9 +380,16 @@ def from_file(self, filename, our_file): 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: @@ -471,6 +603,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), ] From aef286604c5d43d4ab255ed25c764be2e198c571 Mon Sep 17 00:00:00 2001 From: Frazer McLean Date: Tue, 4 Sep 2018 23:39:15 +0200 Subject: [PATCH 02/13] Missing getfloat --- coverage/config.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/coverage/config.py b/coverage/config.py index 179418506..511bdc265 100644 --- a/coverage/config.py +++ b/coverage/config.py @@ -141,6 +141,14 @@ def getint(self, section, option): .format(option, section, value)) return value + def getfloat(self, section, option): + value = self.get(section, option) + if not isinstance(value, float): + raise ValueError( + 'Option {!r} in section {!r} is not a float: {!r}' + .format(option, section, value)) + return value + class HandyConfigParser(configparser.RawConfigParser): """Our specialization of ConfigParser.""" From 1218f7f6131ad2624b41431b2cc664b99a7ed08b Mon Sep 17 00:00:00 2001 From: Frazer McLean Date: Mon, 10 Sep 2018 21:35:45 +0200 Subject: [PATCH 03/13] TOMLConfigParser -> TomlConfigParser --- coverage/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coverage/config.py b/coverage/config.py index 511bdc265..bb602e20a 100644 --- a/coverage/config.py +++ b/coverage/config.py @@ -22,7 +22,7 @@ os = isolate_module(os) -class TOMLConfigParser: +class TomlConfigParser: def __init__(self, our_file): self.getters = [lambda obj: obj['tool']['coverage']] if our_file: @@ -392,7 +392,7 @@ def from_file(self, filename, our_file): if ext == '.toml': if toml is None: return False - cp = TOMLConfigParser(our_file) + cp = TomlConfigParser(our_file) else: cp = HandyConfigParser(our_file) From f6b988b5911c1f7fd85f29ada5b6b151f49567a6 Mon Sep 17 00:00:00 2001 From: Frazer McLean Date: Mon, 10 Sep 2018 21:44:19 +0200 Subject: [PATCH 04/13] fix getfloat for int --- coverage/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/coverage/config.py b/coverage/config.py index bb602e20a..6e7c8d20e 100644 --- a/coverage/config.py +++ b/coverage/config.py @@ -143,6 +143,8 @@ def getint(self, section, option): 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}' From 9eb9d84f53b741e0174c16a3415c3f77f02fc86b Mon Sep 17 00:00:00 2001 From: Frazer McLean Date: Mon, 16 Sep 2019 22:18:49 +0200 Subject: [PATCH 05/13] Move TomlConfigParser --- coverage/config.py | 139 +--------------------------------------- coverage/env.py | 8 +++ coverage/tomlconfig.py | 142 +++++++++++++++++++++++++++++++++++++++++ setup.py | 5 ++ 4 files changed, 158 insertions(+), 136 deletions(-) create mode 100644 coverage/tomlconfig.py diff --git a/coverage/config.py b/coverage/config.py index 6e7c8d20e..1f3383e11 100644 --- a/coverage/config.py +++ b/coverage/config.py @@ -5,152 +5,19 @@ import collections import copy -import io import os import re from coverage import env -from coverage.backward import configparser, iitems, path_types, string_class +from coverage.backward import configparser, iitems, string_class from coverage.misc import contract, CoverageException, isolate_module from coverage.misc import substitute_variables -try: - import toml -except ImportError: - toml = None +from coverage.tomlconfig import TomlConfigParser os = isolate_module(os) -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 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, toml.TomlDecodeError): - continue - 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 - class HandyConfigParser(configparser.RawConfigParser): """Our specialization of ConfigParser.""" @@ -392,7 +259,7 @@ def from_file(self, filename, our_file): """ _, ext = os.path.splitext(filename) if ext == '.toml': - if toml is None: + if not env.TOML_SUPPORT: return False cp = TomlConfigParser(our_file) else: diff --git a/coverage/env.py b/coverage/env.py index 03f763998..1a802ca52 100644 --- a/coverage/env.py +++ b/coverage/env.py @@ -90,3 +90,11 @@ class PYBEHAVIOR(object): # Even when running tests, you can use COVERAGE_TESTING=0 to disable the # test-specific behavior like contracts. TESTING = os.getenv('COVERAGE_TESTING', '') == 'True' + +try: + import toml +except ImportError: + TOML_SUPPORT = False +else: + del toml + TOML_SUPPORT = True diff --git a/coverage/tomlconfig.py b/coverage/tomlconfig.py new file mode 100644 index 000000000..49da5e01f --- /dev/null +++ b/coverage/tomlconfig.py @@ -0,0 +1,142 @@ +import io +import os +import re + +from coverage import env +from coverage.backward import configparser, path_types, string_class +from coverage.misc import CoverageException, substitute_variables + +try: + import toml +except ImportError: + toml = None + + +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 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, toml.TomlDecodeError): + continue + 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/setup.py b/setup.py index b1d086be5..4ea77bf69 100644 --- a/setup.py +++ b/setup.py @@ -95,6 +95,11 @@ ], }, + extras_require={ + # Enable pyproject.toml support + 'toml': ['toml'], + }, + # We need to get HTML assets from our htmlfiles directory. zip_safe=False, From 61faad35980c7975e940b55b6a9072a93b63f0f4 Mon Sep 17 00:00:00 2001 From: Frazer McLean Date: Mon, 10 Sep 2018 23:02:56 +0200 Subject: [PATCH 06/13] Add name to contributors --- CONTRIBUTORS.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 859edcf6d..b6a15cb58 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 From 5d0fc7a929177410a242c23f59aeb0e98cd71fac Mon Sep 17 00:00:00 2001 From: Frazer McLean Date: Sat, 31 Aug 2019 13:38:41 +0200 Subject: [PATCH 07/13] Import toml in backward.py --- coverage/backward.py | 5 +++++ coverage/config.py | 4 ++-- coverage/env.py | 8 -------- coverage/tomlconfig.py | 7 +------ 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/coverage/backward.py b/coverage/backward.py index e8ea3379f..e051fa55d 100644 --- a/coverage/backward.py +++ b/coverage/backward.py @@ -27,6 +27,11 @@ except ImportError: import configparser +try: + import toml +except ImportError: + toml = None + # What's a string called? try: string_class = basestring diff --git a/coverage/config.py b/coverage/config.py index 1f3383e11..901dabaf2 100644 --- a/coverage/config.py +++ b/coverage/config.py @@ -9,7 +9,7 @@ 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 @@ -259,7 +259,7 @@ def from_file(self, filename, our_file): """ _, ext = os.path.splitext(filename) if ext == '.toml': - if not env.TOML_SUPPORT: + if toml is None: return False cp = TomlConfigParser(our_file) else: diff --git a/coverage/env.py b/coverage/env.py index 1a802ca52..03f763998 100644 --- a/coverage/env.py +++ b/coverage/env.py @@ -90,11 +90,3 @@ class PYBEHAVIOR(object): # Even when running tests, you can use COVERAGE_TESTING=0 to disable the # test-specific behavior like contracts. TESTING = os.getenv('COVERAGE_TESTING', '') == 'True' - -try: - import toml -except ImportError: - TOML_SUPPORT = False -else: - del toml - TOML_SUPPORT = True diff --git a/coverage/tomlconfig.py b/coverage/tomlconfig.py index 49da5e01f..b218149f4 100644 --- a/coverage/tomlconfig.py +++ b/coverage/tomlconfig.py @@ -3,14 +3,9 @@ import re from coverage import env -from coverage.backward import configparser, path_types, string_class +from coverage.backward import configparser, path_types, string_class, toml from coverage.misc import CoverageException, substitute_variables -try: - import toml -except ImportError: - toml = None - class TomlConfigParser: def __init__(self, our_file): From 64720d9862464eebf2c62b4aebdb66ddcfd16520 Mon Sep 17 00:00:00 2001 From: Frazer McLean Date: Sat, 31 Aug 2019 15:06:20 +0200 Subject: [PATCH 08/13] fix indentation --- coverage/tomlconfig.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/coverage/tomlconfig.py b/coverage/tomlconfig.py index b218149f4..4ed2922ba 100644 --- a/coverage/tomlconfig.py +++ b/coverage/tomlconfig.py @@ -92,7 +92,7 @@ def getboolean(self, section, option): if not isinstance(value, bool): raise ValueError( 'Option {!r} in section {!r} is not a boolean: {!r}' - .format(option, section, value)) + .format(option, section, value)) return value def getlist(self, section, option): @@ -100,7 +100,7 @@ def getlist(self, section, option): if not isinstance(values, list): raise ValueError( 'Option {!r} in section {!r} is not a list: {!r}' - .format(option, section, values)) + .format(option, section, values)) for i, value in enumerate(values): if isinstance(value, string_class): values[i] = substitute_variables(value, os.environ) @@ -123,7 +123,7 @@ def getint(self, section, option): if not isinstance(value, int): raise ValueError( 'Option {!r} in section {!r} is not an integer: {!r}' - .format(option, section, value)) + .format(option, section, value)) return value def getfloat(self, section, option): @@ -133,5 +133,5 @@ def getfloat(self, section, option): if not isinstance(value, float): raise ValueError( 'Option {!r} in section {!r} is not a float: {!r}' - .format(option, section, value)) + .format(option, section, value)) return value From 330f03dd3292e1b71962ca94f0a5e4d925f287f7 Mon Sep 17 00:00:00 2001 From: Frazer McLean Date: Sat, 31 Aug 2019 15:32:46 +0200 Subject: [PATCH 09/13] Don't ignore TomlDecodeError --- coverage/config.py | 4 ++-- coverage/tomlconfig.py | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/coverage/config.py b/coverage/config.py index 901dabaf2..31e74a8ba 100644 --- a/coverage/config.py +++ b/coverage/config.py @@ -13,7 +13,7 @@ from coverage.misc import contract, CoverageException, isolate_module from coverage.misc import substitute_variables -from coverage.tomlconfig import TomlConfigParser +from coverage.tomlconfig import TomlConfigParser, TomlDecodeError os = isolate_module(os) @@ -269,7 +269,7 @@ def from_file(self, filename, 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 diff --git a/coverage/tomlconfig.py b/coverage/tomlconfig.py index 4ed2922ba..7d8e19341 100644 --- a/coverage/tomlconfig.py +++ b/coverage/tomlconfig.py @@ -7,6 +7,10 @@ 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']] @@ -23,8 +27,10 @@ def read(self, filenames): try: with io.open(filename, encoding='utf-8') as fp: self._data.append(toml.load(fp)) - except (IOError, toml.TomlDecodeError): + 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) From 012157276f3b456afc00c8c1c28b013bc9db8d2a Mon Sep 17 00:00:00 2001 From: Frazer McLean Date: Sat, 31 Aug 2019 15:34:11 +0200 Subject: [PATCH 10/13] Raise if TomlConfigParser is used without toml installed --- coverage/tomlconfig.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/coverage/tomlconfig.py b/coverage/tomlconfig.py index 7d8e19341..0d0846037 100644 --- a/coverage/tomlconfig.py +++ b/coverage/tomlconfig.py @@ -20,6 +20,9 @@ def __init__(self, our_file): 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 = [] From 0066322f2f0c87822eb308e1211e5fdd9388d7e6 Mon Sep 17 00:00:00 2001 From: Frazer McLean Date: Sat, 31 Aug 2019 15:34:26 +0200 Subject: [PATCH 11/13] Add tests for TOML config --- tests/test_config.py | 123 +++++++++++++++++++++++++++++++++++++++++++ tox.ini | 1 + 2 files changed, 124 insertions(+) diff --git a/tests/test_config.py b/tests/test_config.py index ebea18a70..394d59f9c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -56,6 +56,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 +172,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 'foo\(': " + r"(unbalanced parenthesis|missing \))"), + ('[tool.coverage.report]\npartial_branches = ["foo["]\n', + r"Invalid \[report\].partial_branches value '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"'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 @@ 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 = 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 @@ 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 +360,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] diff --git a/tox.ini b/tox.ini index bf636f27f..c164e2cbc 100644 --- a/tox.ini +++ b/tox.ini @@ -15,6 +15,7 @@ deps = -r requirements/pytest.pip pip==19.1.1 setuptools==41.0.1 + 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.24.1 From bc868079fc122d6406f274038092d20ee93250a9 Mon Sep 17 00:00:00 2001 From: Frazer McLean Date: Sat, 31 Aug 2019 17:03:01 +0200 Subject: [PATCH 12/13] Fix test on Python 2 --- tests/test_config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index 394d59f9c..f48a3a3f5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -179,14 +179,14 @@ def test_toml_parse_errors(self): # ("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 'foo\(': " + 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 'foo\[': " + 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"'foo\*\*\*': " + r"u?'foo\*\*\*': " r"multiple repeat"), ('[tool.coverage.run]\nconcurrency="foo"', "not a list"), ("[tool.coverage.report]\nprecision=1.23", "not an integer"), From e54acb96372bc4cd6bfa44e0a74a4b55689fbbef Mon Sep 17 00:00:00 2001 From: Frazer McLean Date: Sun, 15 Sep 2019 15:56:46 +0200 Subject: [PATCH 13/13] Mention toml support in documentation. --- CHANGES.rst | 3 +++ coverage/cmdline.py | 4 ++-- doc/config.rst | 5 ++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index a97f2f27b..52ec00e8d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -45,11 +45,14 @@ Unreleased 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/coverage/cmdline.py b/coverage/cmdline.py index 2bec4ea8c..aead5f865 100644 --- a/coverage/cmdline.py +++ b/coverage/cmdline.py @@ -146,8 +146,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/doc/config.rst b/doc/config.rst index e332f50aa..92a3be155 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