Skip to content

Commit e676347

Browse files
RazerMnedbat
authored andcommitted
TOML support for pyproject.toml and other config files
Squashed and rebased from #699 Missing getfloat TOMLConfigParser -> TomlConfigParser fix getfloat for int Move TomlConfigParser Add name to contributors Import toml in backward.py fix indentation Don't ignore TomlDecodeError Raise if TomlConfigParser is used without toml installed Add tests for TOML config Fix test on Python 2 Mention toml support in documentation.
1 parent 1c06204 commit e676347

File tree

10 files changed

+313
-6
lines changed

10 files changed

+313
-6
lines changed

CHANGES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,14 @@ Version 5.0a7 --- 2019-09-21
8080
plugins, but now it does, closing `issue 834`_.
8181

8282

83+
- Added TOML configuration support, including pyproject.toml `issue 664`_.
84+
8385
.. _issue 720: https://github.com/nedbat/coveragepy/issues/720
8486
.. _issue 822: https://github.com/nedbat/coveragepy/issues/822
8587
.. _issue 834: https://github.com/nedbat/coveragepy/issues/834
8688
.. _issue 829: https://github.com/nedbat/coveragepy/issues/829
8789
.. _issue 846: https://github.com/nedbat/coveragepy/issues/846
90+
.. _issue 664: https://github.com/nedbat/coveragepy/issues/664
8891

8992

9093
.. _changes_50a6:

CONTRIBUTORS.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ Eduardo Schettino
5151
Emil Madsen
5252
Edward Loper
5353
Federico Bond
54+
Frazer McLean
5455
Geoff Bache
5556
George Paci
5657
George Song

coverage/backward.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
# This file does tricky stuff, so disable a pylint warning.
77
# pylint: disable=unused-import
88

9+
import os
910
import sys
1011

1112
from coverage import env
@@ -26,6 +27,11 @@
2627
except ImportError:
2728
import configparser
2829

30+
try:
31+
import toml
32+
except ImportError:
33+
toml = None
34+
2935
# What's a string called?
3036
try:
3137
string_class = basestring
@@ -55,6 +61,15 @@
5561
except ImportError:
5662
from threading import get_ident as get_thread_id
5763

64+
try:
65+
os.PathLike
66+
except AttributeError:
67+
# This is Python 2 and 3
68+
path_types = (bytes, string_class, unicode_class)
69+
else:
70+
# 3.6+
71+
path_types = (bytes, str, os.PathLike)
72+
5873
# shlex.quote is new, but there's an undocumented implementation in "pipes",
5974
# who knew!?
6075
try:

coverage/cmdline.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,8 @@ class Opts(object):
150150
'', '--rcfile', action='store',
151151
help=(
152152
"Specify configuration file. "
153-
"By default '.coveragerc', 'setup.cfg' and 'tox.ini' are tried. "
154-
"[env: COVERAGE_RCFILE]"
153+
"By default '.coveragerc', 'pyproject.toml', 'setup.cfg' and "
154+
"'tox.ini' are tried. [env: COVERAGE_RCFILE]"
155155
),
156156
)
157157
source = optparse.make_option(

coverage/config.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@
99
import re
1010

1111
from coverage import env
12-
from coverage.backward import configparser, iitems, string_class
12+
from coverage.backward import configparser, iitems, string_class, toml
1313
from coverage.misc import contract, CoverageException, isolate_module
1414
from coverage.misc import substitute_variables
1515

16+
from coverage.tomlconfig import TomlConfigParser, TomlDecodeError
17+
1618
os = isolate_module(os)
1719

1820

@@ -256,12 +258,19 @@ def from_file(self, filename, our_file):
256258
coverage.py settings in it.
257259
258260
"""
261+
_, ext = os.path.splitext(filename)
262+
if ext == '.toml':
263+
if toml is None:
264+
return False
265+
cp = TomlConfigParser(our_file)
266+
else:
267+
cp = HandyConfigParser(our_file)
268+
259269
self.attempted_config_files.append(filename)
260270

261-
cp = HandyConfigParser(our_file)
262271
try:
263272
files_read = cp.read(filename)
264-
except configparser.Error as err:
273+
except (configparser.Error, TomlDecodeError) as err:
265274
raise CoverageException("Couldn't read config file %s: %s" % (filename, err))
266275
if not files_read:
267276
return False
@@ -473,6 +482,7 @@ def config_files_to_try(config_file):
473482
config_file = ".coveragerc"
474483
files_to_try = [
475484
(config_file, True, specified_file),
485+
("pyproject.toml", False, False),
476486
("setup.cfg", False, False),
477487
("tox.ini", False, False),
478488
]

coverage/tomlconfig.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import io
2+
import os
3+
import re
4+
5+
from coverage import env
6+
from coverage.backward import configparser, path_types, string_class, toml
7+
from coverage.misc import CoverageException, substitute_variables
8+
9+
10+
class TomlDecodeError(Exception):
11+
"""An exception class that exists even when toml isn't installed."""
12+
13+
14+
class TomlConfigParser:
15+
def __init__(self, our_file):
16+
self.getters = [lambda obj: obj['tool']['coverage']]
17+
if our_file:
18+
self.getters.append(lambda obj: obj)
19+
20+
self._data = []
21+
22+
def read(self, filenames):
23+
if toml is None:
24+
raise RuntimeError('toml module is not installed.')
25+
26+
if isinstance(filenames, path_types):
27+
filenames = [filenames]
28+
read_ok = []
29+
for filename in filenames:
30+
try:
31+
with io.open(filename, encoding='utf-8') as fp:
32+
self._data.append(toml.load(fp))
33+
except IOError:
34+
continue
35+
except toml.TomlDecodeError as err:
36+
raise TomlDecodeError(*err.args)
37+
if env.PYVERSION >= (3, 6):
38+
filename = os.fspath(filename)
39+
read_ok.append(filename)
40+
return read_ok
41+
42+
def has_option(self, section, option):
43+
for data in self._data:
44+
for getter in self.getters:
45+
try:
46+
getter(data)[section][option]
47+
except KeyError:
48+
continue
49+
return True
50+
return False
51+
52+
def has_section(self, section):
53+
for data in self._data:
54+
for getter in self.getters:
55+
try:
56+
getter(data)[section]
57+
except KeyError:
58+
continue
59+
return section
60+
return False
61+
62+
def options(self, section):
63+
for data in self._data:
64+
for getter in self.getters:
65+
try:
66+
section = getter(data)[section]
67+
except KeyError:
68+
continue
69+
return list(section.keys())
70+
raise configparser.NoSectionError(section)
71+
72+
def get_section(self, section):
73+
d = {}
74+
for opt in self.options(section):
75+
d[opt] = self.get(section, opt)
76+
return d
77+
78+
def get(self, section, option):
79+
found_section = False
80+
for data in self._data:
81+
for getter in self.getters:
82+
try:
83+
section = getter(data)[section]
84+
except KeyError:
85+
continue
86+
87+
found_section = True
88+
try:
89+
value = section[option]
90+
except KeyError:
91+
continue
92+
if isinstance(value, string_class):
93+
value = substitute_variables(value, os.environ)
94+
return value
95+
if not found_section:
96+
raise configparser.NoSectionError(section)
97+
raise configparser.NoOptionError(option, section)
98+
99+
def getboolean(self, section, option):
100+
value = self.get(section, option)
101+
if not isinstance(value, bool):
102+
raise ValueError(
103+
'Option {!r} in section {!r} is not a boolean: {!r}'
104+
.format(option, section, value))
105+
return value
106+
107+
def getlist(self, section, option):
108+
values = self.get(section, option)
109+
if not isinstance(values, list):
110+
raise ValueError(
111+
'Option {!r} in section {!r} is not a list: {!r}'
112+
.format(option, section, values))
113+
for i, value in enumerate(values):
114+
if isinstance(value, string_class):
115+
values[i] = substitute_variables(value, os.environ)
116+
return values
117+
118+
def getregexlist(self, section, option):
119+
values = self.getlist(section, option)
120+
for value in values:
121+
value = value.strip()
122+
try:
123+
re.compile(value)
124+
except re.error as e:
125+
raise CoverageException(
126+
"Invalid [%s].%s value %r: %s" % (section, option, value, e)
127+
)
128+
return values
129+
130+
def getint(self, section, option):
131+
value = self.get(section, option)
132+
if not isinstance(value, int):
133+
raise ValueError(
134+
'Option {!r} in section {!r} is not an integer: {!r}'
135+
.format(option, section, value))
136+
return value
137+
138+
def getfloat(self, section, option):
139+
value = self.get(section, option)
140+
if isinstance(value, int):
141+
value = float(value)
142+
if not isinstance(value, float):
143+
raise ValueError(
144+
'Option {!r} in section {!r} is not a float: {!r}'
145+
.format(option, section, value))
146+
return value

doc/config.rst

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ Coverage.py will read settings from other usual configuration files if no other
2929
configuration file is used. It will automatically read from "setup.cfg" or
3030
"tox.ini" if they exist. In this case, the section names have "coverage:"
3131
prefixed, so the ``[run]`` options described below will be found in the
32-
``[coverage:run]`` section of the file.
32+
``[coverage:run]`` section of the file. If Coverage.py is installed with the
33+
``toml`` extra (``pip install coverage[toml]``), it will automatically read
34+
from "pyproject.toml". Configuration must be within the `[tool.coverage]`
35+
section, e.g. ``[tool.coverage.run]`.
3336
3437
3538
Syntax

setup.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@
9393
],
9494
},
9595

96+
extras_require={
97+
# Enable pyproject.toml support
98+
'toml': ['toml'],
99+
},
100+
96101
# We need to get HTML assets from our htmlfiles directory.
97102
zip_safe=False,
98103

0 commit comments

Comments
 (0)