Skip to content

Commit d87cf0e

Browse files
Add support for .slipcoverrc
Adds a coverage.py-style INI config file alongside the existing [tool.slipcover] table in pyproject.toml. Keys are read from [run] and [report] and merged into one flat set; the split mirrors coverage.py's but isn't enforced, since SlipCover's options don't divide cleanly between collection and reporting. Keys may be spelled with hyphens or underscores. Booleans accept configparser's usual states, and source/omit accept either a comma-separated string or one entry per line. Values are read without interpolation, so a literal % needs no escaping. Only [run] and [report] are read; any other section, [DEFAULT] included, warns and is ignored. Both files are read when both exist and settings merge key by key: a key set in .slipcoverrc overrides the same key in [tool.slipcover], while keys it doesn't mention keep their pyproject.toml values. Letting one file replace the other wholesale would mean an rc file setting a single key silently discarded the rest of the configuration. Command-line arguments continue to take precedence over both. --rcfile PATH selects a specific file instead of searching for one; a path that doesn't exist is an error rather than a silent fallback. Discovery otherwise reuses the walk pyproject.toml already used, so the two files are found independently. Diagnostics name the file actually in play and the key at fault, which matters more here than for TOML: every INI value arrives as a string, so a mistyped number is the ordinary failure mode rather than an unusual one. Malformed files produce a clean error rather than a traceback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 85939fc commit d87cf0e

4 files changed

Lines changed: 784 additions & 23 deletions

File tree

README.md

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,54 @@ xml-package-depth = 3
116116

117117
Most command-line flags have a matching key (use hyphens, as shown above);
118118
`source` and `omit` also accept a TOML array instead of a single
119-
comma-separated string. `--merge`, `-m`/module, the script argument, `--version`,
120-
and `--help` are per-invocation choices rather than settings, so they aren't
121-
configurable this way. Command-line arguments always take precedence over
119+
comma-separated string. `--merge`, `-m`/module, the script argument, `--rcfile`,
120+
`--version`, and `--help` are per-invocation choices rather than settings, so
121+
they aren't configurable this way. Command-line arguments always take precedence over
122122
values in `pyproject.toml`, so you can override any setting on a per-run basis.
123123

124+
### Configuration via `.slipcoverrc`
125+
If you prefer coverage.py's INI style, you can instead put the same settings in
126+
a `.slipcoverrc` file, which SlipCover discovers the same way it discovers
127+
`pyproject.toml`.
128+
129+
```ini
130+
[run]
131+
branch = true
132+
source = src
133+
lib
134+
omit = tests/*
135+
immediate = false
136+
threshold = 75
137+
138+
[report]
139+
fail-under = 80.0
140+
json = true
141+
xml = false
142+
pretty-print = true
143+
skip-covered = true
144+
out = coverage.json
145+
missing-width = 120
146+
xml-package-depth = 3
147+
```
148+
149+
Keys may be written with either hyphens or underscores (`fail-under` and
150+
`fail_under` both work), though giving the same key both spellings within one
151+
section is an error. Keys may appear in either section — the split between
152+
`[run]` and `[report]` mirrors coverage.py's, but SlipCover doesn't enforce it.
153+
Only those two sections are read; any other, including `[DEFAULT]`, is reported
154+
as unknown and ignored.
155+
Booleans accept `true`/`false`, `yes`/`no`, `on`/`off` and `1`/`0`; `source` and
156+
`omit` accept either a comma-separated string or one entry per line.
157+
158+
Use `--rcfile PATH` to point at a specific file instead of searching for one.
159+
160+
Both files are read when both exist, and settings are merged key by key: a key
161+
set in `.slipcoverrc` overrides the same key in `[tool.slipcover]`, while keys
162+
the rc file doesn't mention keep their `pyproject.toml` values. Command-line
163+
arguments still take precedence over both. The two files are searched for
164+
independently, so a `.slipcoverrc` found further up the directory tree still
165+
overrides a `pyproject.toml` in the current directory.
166+
124167
## Usage example
125168
```console
126169
$ python3 -m slipcover -m pytest

src/slipcover/__main__.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,8 @@ def __call__(self, parser, namespace, values, option_string=None):
187187

188188
def main():
189189
import argparse
190-
from slipcover.config import read_config, apply_config
190+
import configparser
191+
from slipcover.config import find_pyproject, find_rcfile, read_config, read_rcfile, apply_config
191192

192193
#
193194
# The intended usage is:
@@ -219,6 +220,8 @@ def main():
219220
ap.add_argument('--threshold', type=int, default=50, metavar="T",
220221
help="threshold for de-instrumentation (if not immediate)")
221222
ap.add_argument('--missing-width', type=int, default=80, metavar="WIDTH", help="maximum width for `missing' column")
223+
ap.add_argument('--rcfile', type=Path, metavar="PATH",
224+
help="read configuration from this file instead of searching for .slipcoverrc")
222225

223226
# intended for slipcover development only
224227
ap.add_argument('--silent', action='store_true', help=argparse.SUPPRESS)
@@ -251,13 +254,31 @@ def main():
251254
else:
252255
args = ap.parse_args(sys.argv[1:])
253256

254-
# Apply [tool.slipcover] from pyproject.toml; CLI flags take precedence
257+
# Apply [tool.slipcover] from pyproject.toml, then .slipcoverrc on top, so
258+
# that a key set in both is taken from the rc file; CLI flags beat either.
259+
# Applying per key rather than letting one file replace the other keeps an
260+
# rc file that sets a single key from discarding the rest of pyproject.toml.
261+
if args.rcfile is not None and not args.rcfile.is_file():
262+
print(f"slipcover: no such file: {args.rcfile}", file=sys.stderr)
263+
return 1
264+
265+
rcfile = args.rcfile if args.rcfile is not None else find_rcfile()
266+
pyproject = find_pyproject()
267+
268+
config_file = pyproject
255269
try:
256-
config = read_config()
257-
if config:
258-
apply_config(config, args, explicit_args)
259-
except (ValueError, TypeError) as e:
260-
print(f"slipcover: error in pyproject.toml configuration: {e}", file=sys.stderr)
270+
if pyproject is not None:
271+
config = read_config(pyproject)
272+
if config:
273+
apply_config(config, args, explicit_args, source="[tool.slipcover]")
274+
275+
if rcfile is not None:
276+
config_file = rcfile
277+
config = read_rcfile(rcfile)
278+
if config:
279+
apply_config(config, args, explicit_args, source=str(rcfile))
280+
except (ValueError, TypeError, configparser.Error) as e:
281+
print(f"slipcover: error in {config_file} configuration: {e}", file=sys.stderr)
261282
return 1
262283

263284

src/slipcover/config.py

Lines changed: 129 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
"""Read and apply [tool.slipcover] configuration from pyproject.toml."""
1+
"""Read and apply slipcover configuration from .slipcoverrc or pyproject.toml."""
22

3+
import configparser
34
from pathlib import Path
45

56
try:
@@ -14,9 +15,15 @@
1415
# Maximum number of parent directories to walk up from the start.
1516
_MAX_WALK = 3
1617

18+
_RCFILE_NAME = ".slipcoverrc"
19+
20+
# coverage.py splits its settings between these two sections, and users
21+
# reasonably guess either one; both are read, and neither is enforced.
22+
_RCFILE_SECTIONS = ("run", "report")
1723

18-
def find_pyproject(start=None):
19-
"""Walks up from 'start' (default cwd) looking for pyproject.toml.
24+
25+
def _find_upwards(filename, start):
26+
"""Walks up from 'start' (default cwd) looking for 'filename'.
2027
2128
The search stops and returns None when any of these boundaries is
2229
reached without finding the file:
@@ -33,7 +40,7 @@ def find_pyproject(start=None):
3340
home = Path.home()
3441

3542
for depth, directory in enumerate((start, *start.parents)):
36-
candidate = directory / "pyproject.toml"
43+
candidate = directory / filename
3744
if candidate.is_file():
3845
return candidate
3946

@@ -47,6 +54,22 @@ def find_pyproject(start=None):
4754
return None
4855

4956

57+
def find_pyproject(start=None):
58+
"""Walks up from 'start' (default cwd) looking for pyproject.toml.
59+
60+
See _find_upwards() for the boundaries that stop the search.
61+
"""
62+
return _find_upwards("pyproject.toml", start)
63+
64+
65+
def find_rcfile(start=None):
66+
"""Walks up from 'start' (default cwd) looking for .slipcoverrc.
67+
68+
Uses the same boundaries as find_pyproject().
69+
"""
70+
return _find_upwards(_RCFILE_NAME, start)
71+
72+
5073
def read_config(path=None):
5174
"""Returns the [tool.slipcover] table from a pyproject.toml.
5275
@@ -89,13 +112,102 @@ def read_config(path=None):
89112
}
90113

91114

92-
def apply_config(config, parsed_args, explicit_args=None):
115+
def _parse_rcfile_bool(key, value):
116+
try:
117+
return configparser.ConfigParser.BOOLEAN_STATES[value.strip().lower()]
118+
except KeyError:
119+
raise ValueError(
120+
f"key '{key}' must be a boolean, got '{value}'"
121+
) from None
122+
123+
124+
def read_rcfile(path=None):
125+
"""Returns the slipcover settings held in a .slipcoverrc (INI) file.
126+
127+
If 'path' is None, find_rcfile() is used to locate the file.
128+
The [run] and [report] sections are merged into a single dict shaped
129+
like read_config()'s, ready to hand to apply_config().
130+
Returns an empty dict when no file is found.
131+
"""
132+
if path is None:
133+
path = find_rcfile()
134+
135+
if path is None:
136+
return {}
137+
138+
# No interpolation: a literal '%' in a value (an omit pattern, an
139+
# output name) is a plain character, not syntax to escape.
140+
# The default section is renamed out of the way so [DEFAULT] is read as
141+
# an ordinary -- and thus unknown -- section, rather than seeding every
142+
# other section's options and colliding with the duplicate check below.
143+
parser = configparser.ConfigParser(interpolation=None,
144+
default_section="__slipcover_no_default__")
145+
# utf-8-sig also accepts the BOM Notepad and PowerShell 5.1 write; it
146+
# is plain utf-8 otherwise.
147+
with open(path, encoding="utf-8-sig") as f:
148+
parser.read_file(f)
149+
150+
for section in parser.sections():
151+
if section not in _RCFILE_SECTIONS:
152+
import warnings
153+
warnings.warn(f"Unknown {path} section: '[{section}]'")
154+
155+
config = {}
156+
157+
# A key repeated across the two sections is a legitimate override --
158+
# [report] is read last and wins -- so only same-section duplicates
159+
# are checked below.
160+
for section in _RCFILE_SECTIONS:
161+
if not parser.has_section(section):
162+
continue
163+
164+
spellings = {}
165+
166+
for name, value in parser.items(section):
167+
# configparser lowercases names; accept coverage.py's
168+
# underscores as well as slipcover's own hyphens.
169+
key = name.replace("_", "-")
170+
171+
# configparser keeps 'fail-under' and 'fail_under' as distinct
172+
# options, so normalizing would let the second silently win;
173+
# an identically-spelled duplicate is a DuplicateOptionError.
174+
if key in spellings:
175+
raise ValueError(
176+
f"[{section}] key '{key}' given twice, "
177+
f"as '{spellings[key]}' and '{name}'"
178+
)
179+
spellings[key] = name
180+
181+
if key in _BOOL_KEYS:
182+
# apply_config() requires a real bool, and INI has no types.
183+
config[key] = _parse_rcfile_bool(key, value)
184+
elif key in ("source", "omit"):
185+
# coverage.py writes these one per line; the CLI wants them
186+
# comma-separated.
187+
config[key] = ",".join(
188+
item
189+
for line in value.splitlines()
190+
for item in (part.strip() for part in line.split(","))
191+
if item
192+
)
193+
else:
194+
# Left as a string: apply_config() coerces known keys and
195+
# warns about the rest.
196+
config[key] = value
197+
198+
return config
199+
200+
201+
def apply_config(config, parsed_args, explicit_args=None,
202+
source="[tool.slipcover]"):
93203
"""Merges config values into parsed_args.
94204
95205
Keys whose dest name appears in 'explicit_args' are skipped so that
96206
command-line flags always take precedence over the config file.
207+
'source' names where the config came from, for diagnostics.
97208
98-
Raises TypeError if a boolean key has a non-boolean value.
209+
Raises TypeError if a value's type is wrong for its key, and
210+
ValueError if a value can't be coerced to the key's type.
99211
Emits a UserWarning for unrecognised keys.
100212
"""
101213
if explicit_args is None:
@@ -111,7 +223,7 @@ def apply_config(config, parsed_args, explicit_args=None):
111223
if key in _BOOL_KEYS:
112224
if not isinstance(value, bool):
113225
raise TypeError(
114-
f"[tool.slipcover] key '{key}' must be a boolean, got {type(value).__name__}"
226+
f"{source} key '{key}' must be a boolean, got {type(value).__name__}"
115227
)
116228
setattr(parsed_args, dest, value)
117229

@@ -121,8 +233,16 @@ def apply_config(config, parsed_args, explicit_args=None):
121233
# expects, rather than stringifying the Python list itself.
122234
if key in ("source", "omit") and isinstance(value, list):
123235
value = ",".join(str(v) for v in value)
124-
setattr(parsed_args, dest, _VALUE_KEYS[key](value))
236+
# Path("") is Path('.'), so an empty 'out' would only surface much
237+
# later, as an IsADirectoryError while writing the report.
238+
if key == "out" and isinstance(value, str) and not value.strip():
239+
raise ValueError(f"key '{key}': must not be empty")
240+
try:
241+
coerced = _VALUE_KEYS[key](value)
242+
except (ValueError, TypeError) as e:
243+
raise type(e)(f"key '{key}': {e}") from None
244+
setattr(parsed_args, dest, coerced)
125245

126246
else:
127247
import warnings
128-
warnings.warn(f"Unknown [tool.slipcover] key: '{key}'")
248+
warnings.warn(f"Unknown {source} key: '{key}'")

0 commit comments

Comments
 (0)