Skip to content

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. Either src_root is wrong, or your source is a flat package of top-level modules, which wants units = ["*.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.