Configuration¶
Everything lives under [tool.tepyd] in pyproject.toml. tepyd init writes a starting point for you; this is the full reference.
With no section at all, the defaults below apply, except exclude, which is always empty unless set. Exclusions are per-project policy: baking them into every project's defaults would drop real packages elsewhere without saying so.
A complete example¶
[tool.tepyd]
src_root = "src/app" # filesystem root of the analysed source
src_package = "app" # dotted import path; reserved — no lens reads it yet
# How to slice the source tree into units (globs, first-match-wins): explode
# each package under modules/ one level deep, then take every other
# top-level package as a unit.
units = ["modules/*", "*"]
# Line counter: "internal" (built-in, no dependency) or "cloc".
counter = "internal"
# Units every unit test may import without it counting as a leak (`reach`).
shared = ["models", "settings"]
# Source units excluded from analysis — a reason is REQUIRED, so the policy
# decision is documented where it's made.
[tool.tepyd.exclude]
faker = "seed/fake-data generator, exercised via fixtures"
# Test tiers, cheapest first (bottom of the pyramid first). Any number.
[[tool.tepyd.tiers]]
name = "a_unit"
root = "tests/a_unit"
label = "unit"
target_share = 0.60 # policy gate: ≥ 60 % of test LOC should be here
[[tool.tepyd.tiers]]
name = "b_integration"
root = "tests/b_integration"
label = "integration"
expects = ["repositories/*", "infrastructure/*"]
[[tool.tepyd.tiers]]
name = "c_e2e"
root = "tests/c_e2e"
label = "http-e2e"
[[tool.tepyd.tiers]]
name = "e2e_playwright"
root = "e2e_playwright" # an arbitrary root — needn't live under tests/
label = "browser"
strip_prefix = "modules/" # this tier flattens the layout: modules/biz → biz
Top-level keys¶
| Key | Default | Meaning |
|---|---|---|
src_root |
"src/app" |
Filesystem root of the analysed source, relative to the project root. |
src_package |
"app" |
Dotted import path of the source. Currently informational; no lens reads it (cover keys off src_root). |
units |
["modules/*", "*"] |
Glob patterns slicing the source tree into units. First match wins. |
counter |
"internal" |
internal (tokenize-based; counts non-blank, non-comment lines) or cloc. |
shared |
[] |
Unit names every unit test may import without it counting as a leak. See reach. Matches the unit and its subtree. |
exclude |
{} |
Table of unit = "reason". The reason is required. |
tiers |
four tiers | Array of tables, cheapest-first. |
units¶
The patterns decide both what a unit is and where its tests live; Source unit has the detail. In short: a pattern whose last segment ends in .py yields module units, anything else yields package units, and the two can be mixed.
units = ["*"] # every top-level package
units = ["*.py"] # flat package: every top-level module
units = ["*", "*.py"] # sub-packages plus stray modules
units = ["core/*.py", "lenses/*.py", "*.py"] # module granularity inside packages
counter¶
internal is a tokenize-based counter with no external dependency, so installing Tepyd is enough. A "code line" is any physical line bearing a real token; blank lines and comments are not counted, while a multi-line string counts as one line.
With cloc, Tepyd runs the external cloc binary, which counts more strictly and across languages. Opt in when you have it and want it.
exclude¶
[tool.tepyd.exclude]
faker = "seed/fake-data generator, exercised via fixtures"
generated = "protobuf output, not hand-written"
The reason is required: Tepyd raises a configuration error without one. An exclusion is a policy decision; the reason documents it where it is made.
Excluding a unit drops it from every lens and every tier. Use it for code with no behaviour to test anywhere: a pure Protocol/ports layer, generated code, a fixture factory. If a unit is merely tested at a different tier, you want expects instead.
Orphan detection in gaps ignores exclusions: excluded source still exists, so its tests are not orphans.
Per-tier keys¶
Tiers are an ordered array, cheapest-first. The first tier is the unit tier that unit_share and reach key off; the last is the expensive cap that the shape glyph uses.
| Key | Required | Meaning |
|---|---|---|
name |
yes | Identifier, used by --tier and in --json. |
root |
yes | Directory of this tier's tests, relative to the project root. Need not live under tests/. |
label |
no | Column heading and prose name. Defaults to name. |
target_share |
no | 0–1. A policy gate: the fraction of all test LOC that should live at this tier. Only meaningful on the first tier. |
expects |
no | Globs over unit names scoping the tier to the units it should test. Unset means every unit. See layer awareness. |
strip_prefix |
no | Mapping rewrite for a tier that flattens the layout: with strip_prefix = "modules/", unit modules/biz maps to <root>/biz/. |
A minimal config¶
A different project just describes itself. This one has a flat src/ and two tiers:
[tool.tepyd]
src_root = "src"
src_package = "mypkg"
units = ["*"]
[[tool.tepyd.tiers]]
name = "unit"
root = "tests/unit"
[[tool.tepyd.tiers]]
name = "e2e"
root = "tests/e2e"
Troubleshooting¶
- "no source units found under …"
- Tepyd found nothing under
src_root. Eithersrc_rootis wrong, or your source is a flat package of top-level modules, which wantsunits = ["*.py"]. - Units exist but everything reports "no tests"
- Tepyd locates tests by mirroring. Check that your test directories match your unit names, or see when Tepyd cannot attribute your tests.
- Top-level modules missing from the output
units = ["*"]is directories-only. Add"*.py".- Every layer reports gaps at every tier
- You want
expects.