Skip to content

Commit 63fb96d

Browse files
committed
fix: in toml config, only apply environment substitution to coverage settings. #1481
1 parent 89aabf3 commit 63fb96d

File tree

4 files changed

+65
-35
lines changed

4 files changed

+65
-35
lines changed

CHANGES.rst

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,17 @@ Unreleased
3030
- A ``[paths]`` setting like ``*/foo`` will now match ``foo/bar.py`` so that
3131
relative file paths can be combined more easily.
3232

33-
- Fix internal logic that prevented coverage.py from running on implementations
34-
other than CPython or PyPy (`issue 1474`_).
33+
- Fixed environment variable expansion in pyproject.toml files. It was overly
34+
broad, causing errors outside of coverage.py settings, as described in `issue
35+
1481`_. This is now fixed, but in rare cases will require changing your
36+
pyproject.toml to quote non-string values using environment substitution.
37+
38+
- Fixed internal logic that prevented coverage.py from running on
39+
implementations other than CPython or PyPy (`issue 1474`_).
3540

3641
.. _issue 991: https://github.com/nedbat/coveragepy/issues/991
3742
.. _issue 1474: https://github.com/nedbat/coveragepy/issues/1474
43+
.. _issue 1481: https://github.com/nedbat/coveragepy/issues/1481
3844

3945

4046
.. _changes_6-5-0:

coverage/tomlconfig.py

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ def read(self, filenames):
5252
except OSError:
5353
return []
5454
if tomllib is not None:
55-
toml_text = substitute_variables(toml_text, os.environ)
5655
try:
5756
self.data = tomllib.loads(toml_text)
5857
except tomllib.TOMLDecodeError as err:
@@ -101,9 +100,16 @@ def _get(self, section, option):
101100
if data is None:
102101
raise configparser.NoSectionError(section)
103102
try:
104-
return name, data[option]
103+
value = data[option]
105104
except KeyError as exc:
106105
raise configparser.NoOptionError(option, name) from exc
106+
return name, value
107+
108+
def _get_simple(self, section, option):
109+
name, value = self._get(section, option)
110+
if isinstance(value, str):
111+
value = substitute_variables(value, os.environ)
112+
return name, value
107113

108114
def has_option(self, section, option):
109115
_, data = self._get_section(section)
@@ -126,29 +132,40 @@ def get_section(self, section):
126132
return data
127133

128134
def get(self, section, option):
129-
_, value = self._get(section, option)
135+
_, value = self._get_simple(section, option)
130136
return value
131137

132-
def _check_type(self, section, option, value, type_, type_desc):
133-
if not isinstance(value, type_):
134-
raise ValueError(
135-
'Option {!r} in section {!r} is not {}: {!r}'
136-
.format(option, section, type_desc, value)
137-
)
138+
def _check_type(self, section, option, value, type_, converter, type_desc):
139+
if isinstance(value, type_):
140+
return value
141+
if isinstance(value, str) and converter is not None:
142+
try:
143+
return converter(value)
144+
except Exception:
145+
raise ValueError(
146+
f"Option [{section}]{option} couldn't convert to {type_desc}: {value!r}"
147+
) from None
148+
raise ValueError(
149+
f"Option [{section}]{option} is not {type_desc}: {value!r}"
150+
)
138151

139152
def getboolean(self, section, option):
140-
name, value = self._get(section, option)
141-
self._check_type(name, option, value, bool, "a boolean")
142-
return value
153+
name, value = self._get_simple(section, option)
154+
bool_strings = {"true": True, "false": False}
155+
return self._check_type(name, option, value, bool, bool_strings.__getitem__, "a boolean")
143156

144-
def getlist(self, section, option):
157+
def _getlist(self, section, option):
145158
name, values = self._get(section, option)
146-
self._check_type(name, option, values, list, "a list")
159+
values = self._check_type(name, option, values, list, None, "a list")
160+
values = [substitute_variables(value, os.environ) for value in values]
161+
return name, values
162+
163+
def getlist(self, section, option):
164+
_, values = self._getlist(section, option)
147165
return values
148166

149167
def getregexlist(self, section, option):
150-
name, values = self._get(section, option)
151-
self._check_type(name, option, values, list, "a list")
168+
name, values = self._getlist(section, option)
152169
for value in values:
153170
value = value.strip()
154171
try:
@@ -158,13 +175,11 @@ def getregexlist(self, section, option):
158175
return values
159176

160177
def getint(self, section, option):
161-
name, value = self._get(section, option)
162-
self._check_type(name, option, value, int, "an integer")
163-
return value
178+
name, value = self._get_simple(section, option)
179+
return self._check_type(name, option, value, int, int, "an integer")
164180

165181
def getfloat(self, section, option):
166-
name, value = self._get(section, option)
182+
name, value = self._get_simple(section, option)
167183
if isinstance(value, int):
168184
value = float(value)
169-
self._check_type(name, option, value, float, "a float")
170-
return value
185+
return self._check_type(name, option, value, float, float, "a float")

doc/config.rst

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,14 @@ Coverage.py will read settings from other usual configuration files if no other
3131
configuration file is used. It will automatically read from "setup.cfg" or
3232
"tox.ini" if they exist. In this case, the section names have "coverage:"
3333
prefixed, so the ``[run]`` options described below will be found in the
34-
``[coverage:run]`` section of the file. If coverage.py is installed with the
35-
``toml`` extra (``pip install coverage[toml]``), it will automatically read
36-
from "pyproject.toml". Configuration must be within the ``[tool.coverage]``
37-
section, for example, ``[tool.coverage.run]``.
34+
``[coverage:run]`` section of the file.
35+
36+
Coverage.py will read from "pyproject.toml" if TOML support is available,
37+
either because you are running on Python 3.11 or later, or because you
38+
installed with the ``toml`` extra (``pip install coverage[toml]``).
39+
Configuration must be within the ``[tool.coverage]`` section, for example,
40+
``[tool.coverage.run]``. Environment variable expansion in values is
41+
available, but only within quoted strings, even for non-string values.
3842

3943

4044
Syntax

tests/test_config.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
"""Test the config file handling for coverage.py"""
55

6-
import math
76
import sys
87
from collections import OrderedDict
98

@@ -89,7 +88,7 @@ def test_toml_config_file(self):
8988
assert cov.config.plugins == ["plugins.a_plugin"]
9089
assert cov.config.precision == 3
9190
assert cov.config.html_title == "tabblo & «ταБЬℓσ»"
92-
assert math.isclose(cov.config.fail_under, 90.5)
91+
assert cov.config.fail_under == 90.5
9392
assert cov.config.get_plugin_options("plugins.a_plugin") == {"hello": "world"}
9493

9594
# Test that our class doesn't reject integers when loading floats
@@ -99,7 +98,7 @@ def test_toml_config_file(self):
9998
fail_under = 90
10099
""")
101100
cov = coverage.Coverage(config_file="pyproject.toml")
102-
assert math.isclose(cov.config.fail_under, 90)
101+
assert cov.config.fail_under == 90
103102
assert isinstance(cov.config.fail_under, float)
104103

105104
def test_ignored_config_file(self):
@@ -200,7 +199,7 @@ def test_parse_errors(self, bad_config, msg):
200199
r"multiple repeat"),
201200
('[tool.coverage.run]\nconcurrency="foo"', "not a list"),
202201
("[tool.coverage.report]\nprecision=1.23", "not an integer"),
203-
('[tool.coverage.report]\nfail_under="s"', "not a float"),
202+
('[tool.coverage.report]\nfail_under="s"', "couldn't convert to a float"),
204203
])
205204
def test_toml_parse_errors(self, bad_config, msg):
206205
# Im-parsable values raise ConfigError, with details.
@@ -230,14 +229,15 @@ def test_environment_vars_in_config(self):
230229
assert cov.config.branch is True
231230
assert cov.config.exclude_list == ["the_$one", "anotherZZZ", "xZZZy", "xy", "huh${X}what"]
232231

233-
@pytest.mark.xfail(reason="updated to demonstrate bug #1481")
234232
def test_environment_vars_in_toml_config(self):
235233
# Config files can have $envvars in them.
236234
self.make_file("pyproject.toml", """\
237235
[tool.coverage.run]
238236
data_file = "$DATA_FILE.fooey"
239-
branch = $BRANCH
237+
branch = "$BRANCH"
240238
[tool.coverage.report]
239+
precision = "$DIGITS"
240+
fail_under = "$FAIL_UNDER"
241241
exclude_lines = [
242242
"the_$$one",
243243
"another${THING}",
@@ -246,15 +246,20 @@ def test_environment_vars_in_toml_config(self):
246246
"huh$${X}what",
247247
]
248248
[othersection]
249+
# This reproduces the failure from https://github.com/nedbat/coveragepy/issues/1481
250+
# When OTHER has a backslash that isn't a valid escape, like \\z (see below).
249251
something = "if [ $OTHER ]; then printf '%s\\n' 'Hi'; fi"
250252
""")
251253
self.set_environ("BRANCH", "true")
254+
self.set_environ("DIGITS", "3")
255+
self.set_environ("FAIL_UNDER", "90.5")
252256
self.set_environ("DATA_FILE", "hello-world")
253257
self.set_environ("THING", "ZZZ")
254258
self.set_environ("OTHER", "hi\\zebra")
255259
cov = coverage.Coverage()
256-
assert cov.config.data_file == "hello-world.fooey"
257260
assert cov.config.branch is True
261+
assert cov.config.precision == 3
262+
assert cov.config.data_file == "hello-world.fooey"
258263
assert cov.config.exclude_list == ["the_$one", "anotherZZZ", "xZZZy", "xy", "huh${X}what"]
259264

260265
def test_tilde_in_config(self):

0 commit comments

Comments
 (0)