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 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