tepyd 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tepyd/__init__.py +12 -0
- tepyd/cli.py +333 -0
- tepyd/config.py +251 -0
- tepyd/cover.py +618 -0
- tepyd/discovery.py +179 -0
- tepyd/init.py +416 -0
- tepyd/loc.py +128 -0
- tepyd/mass.py +231 -0
- tepyd/mirror.py +219 -0
- tepyd/model.py +72 -0
- tepyd/report.py +677 -0
- tepyd-0.5.0.dist-info/METADATA +299 -0
- tepyd-0.5.0.dist-info/RECORD +16 -0
- tepyd-0.5.0.dist-info/WHEEL +4 -0
- tepyd-0.5.0.dist-info/entry_points.txt +3 -0
- tepyd-0.5.0.dist-info/licenses/LICENSE +201 -0
tepyd/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Tepyd — TEst PYramid Doctor.
|
|
2
|
+
|
|
3
|
+
Diagnose a project's test pyramid: its mass (test vs source LOC and shape),
|
|
4
|
+
its structural mirroring of the source tree, and how well each tier actually
|
|
5
|
+
covers the code it claims to.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .cli import main
|
|
11
|
+
|
|
12
|
+
__all__ = ["main"]
|
tepyd/cli.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Command-line entry point for Tepyd.
|
|
2
|
+
|
|
3
|
+
``init`` scaffolds a ``[tool.tepyd]`` config; ``mass``, ``mirror``,
|
|
4
|
+
``cover`` (per-tier focused coverage) and ``report`` (which synthesises the
|
|
5
|
+
others into advice) are implemented. ``audit`` is reserved for a later phase.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import cover, init, mass, mirror, report
|
|
15
|
+
from .config import Config, ConfigError, find_project_root, load_config
|
|
16
|
+
from .discovery import discover_units
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="tepyd",
|
|
22
|
+
description="Tepyd — TEst PYramid Doctor: diagnose a project's test pyramid.",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"-C",
|
|
26
|
+
"--root",
|
|
27
|
+
type=Path,
|
|
28
|
+
default=None,
|
|
29
|
+
metavar="DIR",
|
|
30
|
+
help="Project directory to analyse (default: discover from cwd).",
|
|
31
|
+
)
|
|
32
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
33
|
+
|
|
34
|
+
init_p = sub.add_parser(
|
|
35
|
+
"init",
|
|
36
|
+
help="Guess this project's layout and write a [tool.tepyd] section.",
|
|
37
|
+
)
|
|
38
|
+
init_p.add_argument(
|
|
39
|
+
"--dry-run",
|
|
40
|
+
action="store_true",
|
|
41
|
+
help="Print the section that would be written, without modifying anything.",
|
|
42
|
+
)
|
|
43
|
+
init_p.set_defaults(func=_run_init)
|
|
44
|
+
|
|
45
|
+
mass_p = sub.add_parser(
|
|
46
|
+
"mass",
|
|
47
|
+
help="Test-LOC vs source-LOC, tier mix, and pyramid shape.",
|
|
48
|
+
)
|
|
49
|
+
mass_p.add_argument(
|
|
50
|
+
"--min-src",
|
|
51
|
+
type=int,
|
|
52
|
+
default=20,
|
|
53
|
+
help="Skip source units smaller than this LOC (default 20).",
|
|
54
|
+
)
|
|
55
|
+
_add_json(mass_p)
|
|
56
|
+
_add_exclude(mass_p)
|
|
57
|
+
mass_p.set_defaults(func=_run_mass)
|
|
58
|
+
|
|
59
|
+
mirror_p = sub.add_parser(
|
|
60
|
+
"mirror",
|
|
61
|
+
help="Structural mirroring of the test tree against the source tree.",
|
|
62
|
+
)
|
|
63
|
+
mirror_p.add_argument(
|
|
64
|
+
"--max-depth",
|
|
65
|
+
type=int,
|
|
66
|
+
default=None,
|
|
67
|
+
metavar="N",
|
|
68
|
+
help="Only check source packages within N levels of the source root.",
|
|
69
|
+
)
|
|
70
|
+
_add_json(mirror_p)
|
|
71
|
+
_add_exclude(mirror_p)
|
|
72
|
+
mirror_p.set_defaults(func=_run_mirror)
|
|
73
|
+
|
|
74
|
+
cover_p = sub.add_parser(
|
|
75
|
+
"cover",
|
|
76
|
+
help="Per-tier focused coverage: which tier actually exercises each unit.",
|
|
77
|
+
)
|
|
78
|
+
cover_p.add_argument(
|
|
79
|
+
"--tier",
|
|
80
|
+
action="append",
|
|
81
|
+
default=None,
|
|
82
|
+
metavar="NAME",
|
|
83
|
+
dest="tiers",
|
|
84
|
+
help="Measure only this tier (repeatable; default: all tiers).",
|
|
85
|
+
)
|
|
86
|
+
cover_p.add_argument(
|
|
87
|
+
"--min-src",
|
|
88
|
+
type=int,
|
|
89
|
+
default=20,
|
|
90
|
+
help="Skip units with fewer than this many source statements (default 20).",
|
|
91
|
+
)
|
|
92
|
+
_add_json(cover_p)
|
|
93
|
+
_add_exclude(cover_p)
|
|
94
|
+
cover_p.set_defaults(func=_run_cover)
|
|
95
|
+
|
|
96
|
+
report_p = sub.add_parser(
|
|
97
|
+
"report",
|
|
98
|
+
help="Run every check and explain the results, with advice.",
|
|
99
|
+
)
|
|
100
|
+
report_p.add_argument(
|
|
101
|
+
"--format",
|
|
102
|
+
choices=("text", "md"),
|
|
103
|
+
default="text",
|
|
104
|
+
help="Output format (default: text).",
|
|
105
|
+
)
|
|
106
|
+
report_p.add_argument(
|
|
107
|
+
"--level",
|
|
108
|
+
choices=("newb", "junior", "senior", "expert"),
|
|
109
|
+
default="senior",
|
|
110
|
+
help="How much to explain: newb teaches the concepts, expert is a "
|
|
111
|
+
"terse checklist (default: senior).",
|
|
112
|
+
)
|
|
113
|
+
report_p.add_argument(
|
|
114
|
+
"--min-src",
|
|
115
|
+
type=int,
|
|
116
|
+
default=20,
|
|
117
|
+
help="Skip source units smaller than this LOC (default 20).",
|
|
118
|
+
)
|
|
119
|
+
report_p.add_argument(
|
|
120
|
+
"--max-depth",
|
|
121
|
+
type=int,
|
|
122
|
+
default=None,
|
|
123
|
+
metavar="N",
|
|
124
|
+
help="Mirror check: only source packages within N levels of the root.",
|
|
125
|
+
)
|
|
126
|
+
_add_exclude(report_p)
|
|
127
|
+
report_p.set_defaults(func=_run_report)
|
|
128
|
+
|
|
129
|
+
return parser
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _add_json(parser: argparse.ArgumentParser) -> None:
|
|
133
|
+
parser.add_argument(
|
|
134
|
+
"--json",
|
|
135
|
+
action="store_true",
|
|
136
|
+
help="Emit JSON instead of the human-readable report.",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _add_exclude(parser: argparse.ArgumentParser) -> None:
|
|
141
|
+
parser.add_argument(
|
|
142
|
+
"--exclude",
|
|
143
|
+
action="append",
|
|
144
|
+
default=[],
|
|
145
|
+
metavar="NAME",
|
|
146
|
+
help="Skip this source unit (repeatable), on top of config exclusions.",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _stdin_is_interactive() -> bool:
|
|
151
|
+
try:
|
|
152
|
+
return sys.stdin is not None and sys.stdin.isatty()
|
|
153
|
+
except (ValueError, OSError):
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _interactive_ask(question: str, options: list[str], default: str) -> str:
|
|
158
|
+
"""Prompt the user to choose one of ``options`` (Enter accepts default)."""
|
|
159
|
+
print(question)
|
|
160
|
+
for i, opt in enumerate(options, 1):
|
|
161
|
+
suffix = " (default)" if opt == default else ""
|
|
162
|
+
print(f" {i}) {opt}{suffix}")
|
|
163
|
+
while True:
|
|
164
|
+
raw = input(f"Choice [1-{len(options)}, default {default}]: ").strip()
|
|
165
|
+
if not raw:
|
|
166
|
+
return default
|
|
167
|
+
if raw in options:
|
|
168
|
+
return raw
|
|
169
|
+
if raw.isdigit() and 1 <= int(raw) <= len(options):
|
|
170
|
+
return options[int(raw) - 1]
|
|
171
|
+
print(" Enter a number from the list or a package name.")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _run_init(args: argparse.Namespace) -> int:
|
|
175
|
+
root = find_project_root(args.root or Path.cwd())
|
|
176
|
+
pyproject = root / "pyproject.toml"
|
|
177
|
+
if not pyproject.is_file():
|
|
178
|
+
print(
|
|
179
|
+
f"tepyd init: no pyproject.toml found at {root} — "
|
|
180
|
+
"run from your project root.",
|
|
181
|
+
file=sys.stderr,
|
|
182
|
+
)
|
|
183
|
+
return 2
|
|
184
|
+
if init.has_tepyd_section(root):
|
|
185
|
+
print(
|
|
186
|
+
f"tepyd init: [tool.tepyd] is already present in {pyproject} — "
|
|
187
|
+
"leaving it untouched."
|
|
188
|
+
)
|
|
189
|
+
return 0
|
|
190
|
+
|
|
191
|
+
ask = _interactive_ask if _stdin_is_interactive() else None
|
|
192
|
+
config, notes = init.detect_config(root, ask=ask)
|
|
193
|
+
section = init.render_section(config)
|
|
194
|
+
if args.dry_run:
|
|
195
|
+
print(section, end="")
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
init.write_section(root, section)
|
|
199
|
+
print(f"Wrote [tool.tepyd] to {pyproject}.")
|
|
200
|
+
print(
|
|
201
|
+
f" src_root = {config.src_root} "
|
|
202
|
+
f"tiers: {', '.join(t.name for t in config.tiers)}"
|
|
203
|
+
)
|
|
204
|
+
for note in notes:
|
|
205
|
+
print(f" note: {note}", file=sys.stderr)
|
|
206
|
+
print("Review it, then run `tepyd mass` or `tepyd mirror`.")
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _empty_units_message(config: Config, min_src: int) -> str:
|
|
211
|
+
"""Diagnose *why* no units were analysed: none discovered (likely a flat
|
|
212
|
+
package or wrong src_root) vs. all discovered units below --min-src."""
|
|
213
|
+
if not discover_units(config):
|
|
214
|
+
return (
|
|
215
|
+
f"(no source units found under {config.src_path}. Tepyd treats each "
|
|
216
|
+
"sub-package — a sub-directory of src_root — as a unit, and this "
|
|
217
|
+
"path has none. If your source is a flat package of modules, set "
|
|
218
|
+
'`units = ["*.py"]` in [tool.tepyd] to analyse modules; otherwise '
|
|
219
|
+
"check `src_root`.)"
|
|
220
|
+
)
|
|
221
|
+
return (
|
|
222
|
+
f"(source units were found under {config.src_path}, but none reached "
|
|
223
|
+
f"the --min-src {min_src} LOC threshold — lower it to include smaller "
|
|
224
|
+
"units.)"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _run_mass(args: argparse.Namespace) -> int:
|
|
229
|
+
config = load_config(args.root)
|
|
230
|
+
rows = mass.build_rows(config, min_src=args.min_src, extra_exclude=args.exclude)
|
|
231
|
+
if not rows:
|
|
232
|
+
print(_empty_units_message(config, args.min_src), file=sys.stderr)
|
|
233
|
+
return 1
|
|
234
|
+
if args.json:
|
|
235
|
+
print(mass.emit_json(rows))
|
|
236
|
+
else:
|
|
237
|
+
print(mass.format_table(config, rows))
|
|
238
|
+
print(mass.summary(config, rows))
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _run_mirror(args: argparse.Namespace) -> int:
|
|
243
|
+
config = load_config(args.root)
|
|
244
|
+
report = mirror.build_mirror(
|
|
245
|
+
config,
|
|
246
|
+
max_depth=args.max_depth,
|
|
247
|
+
extra_exclude=tuple(args.exclude),
|
|
248
|
+
)
|
|
249
|
+
if not report.source_packages:
|
|
250
|
+
print(
|
|
251
|
+
f"(no source units found under {config.src_path}. If this is a flat "
|
|
252
|
+
'package of modules, set units = ["*.py"] in [tool.tepyd]; otherwise '
|
|
253
|
+
"check src_root.)",
|
|
254
|
+
file=sys.stderr,
|
|
255
|
+
)
|
|
256
|
+
return 1
|
|
257
|
+
if args.json:
|
|
258
|
+
print(mirror.emit_json(report))
|
|
259
|
+
else:
|
|
260
|
+
print(mirror.format_mirror(report))
|
|
261
|
+
return 0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _run_cover(args: argparse.Namespace) -> int:
|
|
265
|
+
config = load_config(args.root)
|
|
266
|
+
selected = tuple(args.tiers) if args.tiers else None
|
|
267
|
+
try:
|
|
268
|
+
report = cover.run_cover(
|
|
269
|
+
config,
|
|
270
|
+
tiers=selected,
|
|
271
|
+
min_src=args.min_src,
|
|
272
|
+
extra_exclude=tuple(args.exclude),
|
|
273
|
+
)
|
|
274
|
+
except cover.CoverToolError as exc:
|
|
275
|
+
print(f"tepyd cover: {exc}", file=sys.stderr)
|
|
276
|
+
return 2
|
|
277
|
+
|
|
278
|
+
if not report.units:
|
|
279
|
+
# Surface why nothing was measured: failed tiers, or just an empty
|
|
280
|
+
# source tree below the --min-src gate.
|
|
281
|
+
if report.failures:
|
|
282
|
+
print(cover.summary(report), file=sys.stderr)
|
|
283
|
+
print("(no coverage data)", file=sys.stderr)
|
|
284
|
+
else:
|
|
285
|
+
print(_empty_units_message(config, args.min_src), file=sys.stderr)
|
|
286
|
+
return 1
|
|
287
|
+
|
|
288
|
+
# Surface failed/partial tiers on stderr even on the success path, so they
|
|
289
|
+
# aren't lost when stdout is piped to a file. (Exit stays 0 — best-effort;
|
|
290
|
+
# JSON consumers get the full `failures`/`warnings` arrays.)
|
|
291
|
+
for failure in report.failures:
|
|
292
|
+
print(f" tier {failure.tier} not measured: {failure.reason}", file=sys.stderr)
|
|
293
|
+
for warning in report.warnings:
|
|
294
|
+
print(f" tier {warning.tier}: {warning.reason}", file=sys.stderr)
|
|
295
|
+
|
|
296
|
+
if args.json:
|
|
297
|
+
print(cover.emit_json(report))
|
|
298
|
+
else:
|
|
299
|
+
print(cover.format_table(report))
|
|
300
|
+
print(cover.summary(report))
|
|
301
|
+
return 0
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _run_report(args: argparse.Namespace) -> int:
|
|
305
|
+
config = load_config(args.root)
|
|
306
|
+
data = report.build_report(
|
|
307
|
+
config,
|
|
308
|
+
min_src=args.min_src,
|
|
309
|
+
max_depth=args.max_depth,
|
|
310
|
+
extra_exclude=tuple(args.exclude),
|
|
311
|
+
)
|
|
312
|
+
if not data.rows and not data.mirror.source_packages:
|
|
313
|
+
print(
|
|
314
|
+
f"(nothing to analyse under {config.src_path})",
|
|
315
|
+
file=sys.stderr,
|
|
316
|
+
)
|
|
317
|
+
return 1
|
|
318
|
+
print(report.render(config, data, fmt=args.format, level=args.level), end="")
|
|
319
|
+
return 0
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def main(argv: list[str] | None = None) -> int:
|
|
323
|
+
parser = build_parser()
|
|
324
|
+
args = parser.parse_args(argv)
|
|
325
|
+
try:
|
|
326
|
+
return args.func(args)
|
|
327
|
+
except ConfigError as exc:
|
|
328
|
+
print(f"tepyd: configuration error: {exc}", file=sys.stderr)
|
|
329
|
+
return 2
|
|
330
|
+
except FileNotFoundError as exc:
|
|
331
|
+
# e.g. counter='cloc' with cloc absent — message already printed.
|
|
332
|
+
print(f"tepyd: {exc}", file=sys.stderr)
|
|
333
|
+
return 2
|
tepyd/config.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Configuration model for Tepyd, loaded from ``[tool.tepyd]``.
|
|
2
|
+
|
|
3
|
+
Everything the original one-off script hardwired — the source root, the
|
|
4
|
+
tier names and locations, the source-unit slicing rules, the policy
|
|
5
|
+
exclusions — lives here as data, with the author's own layout shipped as
|
|
6
|
+
the default so this project (and others that share the layout) work with
|
|
7
|
+
zero config. Any project that differs overrides only what differs.
|
|
8
|
+
|
|
9
|
+
The schema deliberately keeps a few doors open for later phases without
|
|
10
|
+
paying for them now:
|
|
11
|
+
|
|
12
|
+
- ``src_root``/``src_package`` are single values today, but the loader
|
|
13
|
+
tolerates a *list* so monorepo support (Phase 3+) is an additive change.
|
|
14
|
+
- ``Tier.markers`` feeds the future ``audit`` lens; unset, it costs nothing.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, cast
|
|
22
|
+
|
|
23
|
+
import tomllib
|
|
24
|
+
|
|
25
|
+
# --- Defaults : the author's current layout -------------------------------
|
|
26
|
+
#
|
|
27
|
+
# These mirror `sandbox/cloc_test_ratios.py` so the zero-config experience
|
|
28
|
+
# matches what the script produced. NOTE the one deliberate departure:
|
|
29
|
+
# `exclude` defaults to EMPTY. Exclusions (`faker`, `jobs`) are per-project
|
|
30
|
+
# *policy*, not *layout* — baking them into every project's defaults would
|
|
31
|
+
# silently drop real packages elsewhere. Layout is a default; policy is not.
|
|
32
|
+
|
|
33
|
+
DEFAULT_SRC_ROOT = "src/app"
|
|
34
|
+
DEFAULT_SRC_PACKAGE = "app"
|
|
35
|
+
# Glob patterns slicing the source tree into units, first-match-wins:
|
|
36
|
+
# explode every module one level deep, then take each remaining top-level
|
|
37
|
+
# package as one unit. A pattern's match is skipped if a previous pattern
|
|
38
|
+
# already claimed something beneath it (so `modules/` itself never doubles
|
|
39
|
+
# as a unit once `modules/*` exploded it).
|
|
40
|
+
DEFAULT_UNITS: tuple[str, ...] = ("modules/*", "*")
|
|
41
|
+
|
|
42
|
+
# Default counter: the built-in tokenize-based one, so `pip install tepyd`
|
|
43
|
+
# works with no external binary. `cloc` stays available as an opt-in for
|
|
44
|
+
# those who want its stricter, multi-language counting.
|
|
45
|
+
DEFAULT_COUNTER = "internal"
|
|
46
|
+
VALID_COUNTERS = frozenset({"internal", "cloc"})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ConfigError(ValueError):
|
|
50
|
+
"""Raised when ``[tool.tepyd]`` is present but malformed."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Tier:
|
|
55
|
+
"""One rung of the pyramid — a directory of tests of a given cost.
|
|
56
|
+
|
|
57
|
+
Tiers are ordered cheapest-first in the config; that order is load
|
|
58
|
+
bearing (the first tier is "unit", the last is the most expensive),
|
|
59
|
+
so the pyramid-shape logic needs no per-tier "is this the cap?" flag.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
name: str
|
|
63
|
+
root: str # filesystem path relative to the project root
|
|
64
|
+
label: str = "" # human column header; defaults to `name`
|
|
65
|
+
target_share: float | None = None # policy: min fraction of test LOC
|
|
66
|
+
strip_prefix: str = "" # mapping rewrite for tiers that flatten layout
|
|
67
|
+
# Regex markers for the future `audit` lens (what imports/fixtures say
|
|
68
|
+
# a file in this tier should use). Unused until Phase 3.
|
|
69
|
+
markers: tuple[str, ...] = ()
|
|
70
|
+
|
|
71
|
+
def __post_init__(self) -> None:
|
|
72
|
+
if not self.label:
|
|
73
|
+
# frozen dataclass — assign through object.__setattr__.
|
|
74
|
+
object.__setattr__(self, "label", self.name)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class Config:
|
|
79
|
+
"""A fully-resolved Tepyd configuration for one project."""
|
|
80
|
+
|
|
81
|
+
project_root: Path
|
|
82
|
+
src_root: str
|
|
83
|
+
src_package: str
|
|
84
|
+
units: tuple[str, ...]
|
|
85
|
+
tiers: tuple[Tier, ...]
|
|
86
|
+
exclude: dict[str, str] = field(default_factory=dict) # name -> reason
|
|
87
|
+
counter: str = DEFAULT_COUNTER
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def src_path(self) -> Path:
|
|
91
|
+
"""Absolute path to the analysed source root."""
|
|
92
|
+
return self.project_root / self.src_root
|
|
93
|
+
|
|
94
|
+
def tier(self, name: str) -> Tier:
|
|
95
|
+
for t in self.tiers:
|
|
96
|
+
if t.name == name:
|
|
97
|
+
return t
|
|
98
|
+
raise KeyError(name)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def find_project_root(start: Path) -> Path:
|
|
102
|
+
"""Walk up from ``start`` to the nearest dir holding a pyproject.toml.
|
|
103
|
+
|
|
104
|
+
Falls back to ``start`` itself if none is found, so the tool still runs
|
|
105
|
+
(against defaults) in a directory that simply has no pyproject yet.
|
|
106
|
+
"""
|
|
107
|
+
start = start.resolve()
|
|
108
|
+
for candidate in (start, *start.parents):
|
|
109
|
+
if (candidate / "pyproject.toml").is_file():
|
|
110
|
+
return candidate
|
|
111
|
+
return start
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def load_config(start: Path | None = None) -> Config:
|
|
115
|
+
"""Load ``[tool.tepyd]`` from the project containing ``start`` (or cwd)."""
|
|
116
|
+
root = find_project_root(start or Path.cwd())
|
|
117
|
+
raw: dict[str, Any] = {}
|
|
118
|
+
pyproject = root / "pyproject.toml"
|
|
119
|
+
if pyproject.is_file():
|
|
120
|
+
with pyproject.open("rb") as fh:
|
|
121
|
+
data = tomllib.load(fh)
|
|
122
|
+
raw = data.get("tool", {}).get("tepyd", {})
|
|
123
|
+
return build_config(root, raw)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def build_config(project_root: Path, raw: dict[str, Any]) -> Config:
|
|
127
|
+
"""Turn a parsed ``[tool.tepyd]`` table into a validated `Config`.
|
|
128
|
+
|
|
129
|
+
Separated from `load_config` so tests can feed a dict directly without
|
|
130
|
+
touching the filesystem.
|
|
131
|
+
"""
|
|
132
|
+
src_root = _single_path(raw.get("src_root", DEFAULT_SRC_ROOT), "src_root")
|
|
133
|
+
src_package = _single_path(
|
|
134
|
+
raw.get("src_package", DEFAULT_SRC_PACKAGE), "src_package"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
units = tuple(raw.get("units", DEFAULT_UNITS))
|
|
138
|
+
if not units:
|
|
139
|
+
raise ConfigError("`units` must list at least one glob pattern.")
|
|
140
|
+
|
|
141
|
+
tiers = _parse_tiers(raw.get("tiers"))
|
|
142
|
+
exclude = _parse_exclude(raw.get("exclude", {}))
|
|
143
|
+
|
|
144
|
+
counter = raw.get("counter", DEFAULT_COUNTER)
|
|
145
|
+
if counter not in VALID_COUNTERS:
|
|
146
|
+
raise ConfigError(
|
|
147
|
+
f"`counter` must be one of {sorted(VALID_COUNTERS)}, got {counter!r}."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return Config(
|
|
151
|
+
project_root=project_root,
|
|
152
|
+
src_root=src_root,
|
|
153
|
+
src_package=src_package,
|
|
154
|
+
units=units,
|
|
155
|
+
tiers=tiers,
|
|
156
|
+
exclude=exclude,
|
|
157
|
+
counter=counter,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _single_path(value: object, key: str) -> str:
|
|
162
|
+
"""Accept a string today; reject (with a forward-looking message) the
|
|
163
|
+
list form reserved for monorepo support so the error is actionable."""
|
|
164
|
+
if isinstance(value, list):
|
|
165
|
+
raise ConfigError(
|
|
166
|
+
f"`{key}` as a list (monorepo mode) is not supported yet — "
|
|
167
|
+
"use a single path for now."
|
|
168
|
+
)
|
|
169
|
+
if not isinstance(value, str) or not value:
|
|
170
|
+
raise ConfigError(f"`{key}` must be a non-empty string.")
|
|
171
|
+
# Normalise a trailing slash (a natural way to write a directory) so it
|
|
172
|
+
# can't break path matching downstream (e.g. the cover lens's prefixes).
|
|
173
|
+
normalised = value.rstrip("/")
|
|
174
|
+
if not normalised:
|
|
175
|
+
raise ConfigError(f"`{key}` must be a non-empty string.")
|
|
176
|
+
return normalised
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def default_tiers() -> tuple[Tier, ...]:
|
|
180
|
+
return (
|
|
181
|
+
Tier("a_unit", "tests/a_unit", label="unit", target_share=0.60),
|
|
182
|
+
Tier("b_integration", "tests/b_integration", label="integration"),
|
|
183
|
+
Tier("c_e2e", "tests/c_e2e", label="http-e2e"),
|
|
184
|
+
Tier(
|
|
185
|
+
"e2e_playwright",
|
|
186
|
+
"e2e_playwright",
|
|
187
|
+
label="browser",
|
|
188
|
+
strip_prefix="modules/",
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _parse_tiers(raw_tiers: Any) -> tuple[Tier, ...]:
|
|
194
|
+
if raw_tiers is None:
|
|
195
|
+
return default_tiers()
|
|
196
|
+
if not isinstance(raw_tiers, list) or not raw_tiers:
|
|
197
|
+
raise ConfigError("`tiers` must be a non-empty array of tables.")
|
|
198
|
+
|
|
199
|
+
tiers: list[Tier] = []
|
|
200
|
+
seen: set[str] = set()
|
|
201
|
+
for i, entry in enumerate(raw_tiers):
|
|
202
|
+
if not isinstance(entry, dict):
|
|
203
|
+
raise ConfigError(f"tier #{i} must be a table.")
|
|
204
|
+
# Re-bind to an explicitly-typed local: the parsed TOML table has a
|
|
205
|
+
# dynamic value type, and narrowing alone leaves strict checkers with
|
|
206
|
+
# an unusable `dict[Unknown, Unknown]`.
|
|
207
|
+
table = cast("dict[str, Any]", entry)
|
|
208
|
+
try:
|
|
209
|
+
name = table["name"]
|
|
210
|
+
root = table["root"]
|
|
211
|
+
except KeyError as exc:
|
|
212
|
+
raise ConfigError(f"tier #{i} is missing required key {exc}.") from exc
|
|
213
|
+
if name in seen:
|
|
214
|
+
raise ConfigError(f"duplicate tier name {name!r}.")
|
|
215
|
+
seen.add(name)
|
|
216
|
+
|
|
217
|
+
target = table.get("target_share")
|
|
218
|
+
if target is not None and not (0.0 <= float(target) <= 1.0):
|
|
219
|
+
raise ConfigError(
|
|
220
|
+
f"tier {name!r}: target_share must be in [0, 1], got {target}."
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
tiers.append(
|
|
224
|
+
Tier(
|
|
225
|
+
name=name,
|
|
226
|
+
root=root,
|
|
227
|
+
label=table.get("label", ""),
|
|
228
|
+
target_share=target,
|
|
229
|
+
strip_prefix=table.get("strip_prefix", ""),
|
|
230
|
+
markers=tuple(table.get("markers", ())),
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
return tuple(tiers)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _parse_exclude(raw_exclude: Any) -> dict[str, str]:
|
|
237
|
+
"""Exclusions carry a required, non-empty reason — the policy decision
|
|
238
|
+
is documented at the point it's made, and a future reader can challenge
|
|
239
|
+
it."""
|
|
240
|
+
if not isinstance(raw_exclude, dict):
|
|
241
|
+
raise ConfigError("`exclude` must be a table of name -> reason.")
|
|
242
|
+
table = cast("dict[str, Any]", raw_exclude)
|
|
243
|
+
out: dict[str, str] = {}
|
|
244
|
+
for name, reason in table.items():
|
|
245
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
246
|
+
raise ConfigError(
|
|
247
|
+
f"exclude {name!r} needs a non-empty reason string "
|
|
248
|
+
"(why is this unit not tested?)."
|
|
249
|
+
)
|
|
250
|
+
out[name] = reason
|
|
251
|
+
return out
|