Skip to content

Commit 85939fc

Browse files
Merge pull request #83 from ronaldorcampos/feature/support-pyproject
Add support for pyproject.toml
2 parents d5bab6e + 2710731 commit 85939fc

5 files changed

Lines changed: 510 additions & 1 deletion

File tree

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,36 @@ which starts `pytest`, passing it any options (`-x -v` in this example)
9191
after the module name.
9292
No plug-in is required for pytest.
9393

94+
### Configuration via `pyproject.toml`
95+
Instead of passing options on every command invocation, you can store them
96+
in your project's `pyproject.toml` under the `[tool.slipcover]` section.
97+
SlipCover automatically discovers the nearest `pyproject.toml` by walking up
98+
from the current working directory.
99+
100+
```toml
101+
[tool.slipcover]
102+
branch = true
103+
source = "src" # or ["src", "lib"]
104+
omit = "tests/*" # or ["tests/*", "*.pyc"]
105+
fail-under = 80.0
106+
json = true
107+
xml = false
108+
pretty-print = true
109+
skip-covered = true
110+
immediate = false
111+
out = "coverage.json"
112+
threshold = 75
113+
missing-width = 120
114+
xml-package-depth = 3
115+
```
116+
117+
Most command-line flags have a matching key (use hyphens, as shown above);
118+
`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
122+
values in `pyproject.toml`, so you can override any setting on a per-run basis.
123+
94124
## Usage example
95125
```console
96126
$ python3 -m slipcover -m pytest

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ classifiers = [
2121
]
2222
requires-python = ">=3.9,<3.15"
2323
dependencies = [
24-
"tabulate"
24+
"tabulate",
25+
"tomli; python_version < '3.11'"
2526
]
2627

2728
[project.scripts]

src/slipcover/__main__.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,40 @@ def merge_files(args, base_path):
154154
return 0
155155

156156

157+
def _detect_explicit_args(ap, argv):
158+
"""Returns the set of argparse dest names explicitly provided on the command line."""
159+
import argparse
160+
161+
explicit = set()
162+
163+
class _Track(argparse.Action):
164+
def __call__(self, parser, namespace, values, option_string=None):
165+
explicit.add(self.dest)
166+
setattr(namespace, self.dest, values)
167+
168+
shadow = argparse.ArgumentParser(add_help=False)
169+
for action in ap._actions:
170+
if action.option_strings:
171+
kwargs = {
172+
"dest": action.dest,
173+
"nargs": action.nargs,
174+
"default": action.default,
175+
}
176+
if isinstance(action, argparse._StoreTrueAction):
177+
kwargs["nargs"] = 0
178+
kwargs["const"] = True
179+
elif isinstance(action, argparse._VersionAction):
180+
continue
181+
kwargs["action"] = _Track
182+
shadow.add_argument(*action.option_strings, **kwargs)
183+
184+
shadow.parse_known_args(argv)
185+
return explicit
186+
187+
157188
def main():
158189
import argparse
190+
from slipcover.config import read_config, apply_config
159191

160192
#
161193
# The intended usage is:
@@ -202,13 +234,32 @@ def main():
202234
g.add_argument('script', nargs='?', type=Path, help="the script to run")
203235
ap.add_argument('script_or_module_args', nargs=argparse.REMAINDER)
204236

237+
# Figure out which CLI flags were explicitly provided, so that
238+
# pyproject.toml values don't override them.
239+
if '-m' in sys.argv:
240+
minus_m = sys.argv.index('-m')
241+
cli_argv = sys.argv[1:minus_m+2]
242+
else:
243+
cli_argv = sys.argv[1:]
244+
245+
explicit_args = _detect_explicit_args(ap, cli_argv)
246+
205247
if '-m' in sys.argv: # work around exclusive group not handled properly
206248
minus_m = sys.argv.index('-m')
207249
args = ap.parse_args(sys.argv[1:minus_m+2])
208250
args.script_or_module_args = sys.argv[minus_m+2:]
209251
else:
210252
args = ap.parse_args(sys.argv[1:])
211253

254+
# Apply [tool.slipcover] from pyproject.toml; CLI flags take precedence
255+
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)
261+
return 1
262+
212263

213264
base_path = Path(args.script).resolve().parent if args.script \
214265
else Path('.').resolve()

src/slipcover/config.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""Read and apply [tool.slipcover] configuration from pyproject.toml."""
2+
3+
from pathlib import Path
4+
5+
try:
6+
import tomllib # Python 3.11+
7+
except ImportError:
8+
import tomli as tomllib # type: ignore[no-redef]
9+
10+
11+
# Markers that indicate a project root; stop climbing here.
12+
_ROOT_MARKERS = frozenset({".git", ".hg", ".svn", "setup.py", "setup.cfg"})
13+
14+
# Maximum number of parent directories to walk up from the start.
15+
_MAX_WALK = 3
16+
17+
18+
def find_pyproject(start=None):
19+
"""Walks up from 'start' (default cwd) looking for pyproject.toml.
20+
21+
The search stops and returns None when any of these boundaries is
22+
reached without finding the file:
23+
24+
- a directory containing a VCS/project root marker
25+
(.git, .hg, .svn, setup.py, setup.cfg)
26+
- the user's home directory
27+
- more than _MAX_WALK parent directories have been visited
28+
"""
29+
if start is None:
30+
start = Path.cwd()
31+
start = start.resolve()
32+
33+
home = Path.home()
34+
35+
for depth, directory in enumerate((start, *start.parents)):
36+
candidate = directory / "pyproject.toml"
37+
if candidate.is_file():
38+
return candidate
39+
40+
# Don't climb above a project root, the user's home directory,
41+
# or more than _MAX_WALK levels.
42+
if (depth >= _MAX_WALK
43+
or directory == home
44+
or any((directory / m).exists() for m in _ROOT_MARKERS)):
45+
break
46+
47+
return None
48+
49+
50+
def read_config(path=None):
51+
"""Returns the [tool.slipcover] table from a pyproject.toml.
52+
53+
If 'path' is None, find_pyproject() is used to locate the file.
54+
Returns an empty dict when no file is found or the section is absent.
55+
"""
56+
if path is None:
57+
path = find_pyproject()
58+
59+
if path is None:
60+
return {}
61+
62+
with open(path, "rb") as f:
63+
data = tomllib.load(f)
64+
65+
return data.get("tool", {}).get("slipcover", {})
66+
67+
68+
# Boolean flags (store_true in CLI). Excludes --silent/--dis/--debug/
69+
# --dont-wrap-pytest: those are argparse.SUPPRESS'd, dev-only flags, not
70+
# part of the stable, user-facing config surface.
71+
_BOOL_KEYS = {
72+
"branch",
73+
"json",
74+
"pretty-print",
75+
"xml",
76+
"immediate",
77+
"skip-covered",
78+
}
79+
80+
# Keys that take a value
81+
_VALUE_KEYS = {
82+
"out": Path,
83+
"source": str,
84+
"omit": str,
85+
"fail-under": float,
86+
"threshold": int,
87+
"missing-width": int,
88+
"xml-package-depth": int,
89+
}
90+
91+
92+
def apply_config(config, parsed_args, explicit_args=None):
93+
"""Merges config values into parsed_args.
94+
95+
Keys whose dest name appears in 'explicit_args' are skipped so that
96+
command-line flags always take precedence over the config file.
97+
98+
Raises TypeError if a boolean key has a non-boolean value.
99+
Emits a UserWarning for unrecognised keys.
100+
"""
101+
if explicit_args is None:
102+
explicit_args = set()
103+
104+
for key, value in config.items():
105+
dest = key.replace("-", "_")
106+
107+
# CLI flags always win
108+
if dest in explicit_args:
109+
continue
110+
111+
if key in _BOOL_KEYS:
112+
if not isinstance(value, bool):
113+
raise TypeError(
114+
f"[tool.slipcover] key '{key}' must be a boolean, got {type(value).__name__}"
115+
)
116+
setattr(parsed_args, dest, value)
117+
118+
elif key in _VALUE_KEYS:
119+
# TOML's idiomatic way to express multiple values is an array;
120+
# join it the way --source/--omit's comma-separated CLI form
121+
# expects, rather than stringifying the Python list itself.
122+
if key in ("source", "omit") and isinstance(value, list):
123+
value = ",".join(str(v) for v in value)
124+
setattr(parsed_args, dest, _VALUE_KEYS[key](value))
125+
126+
else:
127+
import warnings
128+
warnings.warn(f"Unknown [tool.slipcover] key: '{key}'")

0 commit comments

Comments
 (0)