Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

dermestes

Named after Dermestes, the hide beetles museums use to clean a specimen down to bare skeleton: everything that is not structure goes.

dermestes finds abstractions in Python code that have not earned their keep, and reports them as deletion candidates an agent can act on: an ABC or Protocol with one implementation, a function with one caller, a parameter nobody varies.

It is deterministic and offline. It never imports or runs your code, never rewrites it, and prints nothing when there is nothing to report.

For your CLAUDE.md

### `dermestes` — over-abstraction

- `dermestes` lists abstractions your change introduced that have a single
  user (an ABC with one implementation, a function with one caller).
- Each finding ends in a `suggest:` line. Apply it and run the tests, or keep
  it with `# dermestes: keep <reason>` in the definition's header.

Setup dermestes in this repository

dermestes is not configured in this repository yet. Run everything below at the repository root.

1. Install. It is a single binary shipped as a Python wheel; it never imports or runs your code.

uv add --dev dermestes

2. Configure. An empty table is enough; it is also what makes dermestes guide switch to triage.

[tool.dermestes]

3. Run it on your changes. With no flags it reports only what the working tree introduced against HEAD. --base <REF> compares against another ref; --all audits the whole repository.

4. Exit codes. 0 and no output when there is nothing to report, 1 when findings were printed, 2 on a usage or internal error.

next: run dermestes

Triage a dermestes finding

Each finding is a deletion candidate: an abstraction with a single user, or none.

const-param src/db.py:209 insert_history
  calls: 2 prod, 0 test
  duration_s: default None never overridden
  suggest: drop duration_s, use None inline
pass-through src/entries.py:474 resolve_relation
  form: forward → src/entries.py:459 resolve_entry; calls: 1 prod, 0 test
  suggest: call resolve_entry directly at src/entries.py:492, delete resolve_relation
dead-code src/report.py:543 _fetch_cost_rows
  calls: 0 prod, 2 test
  tests: tests/test_report.py:30, tests/test_report.py:41
  suggest: delete _fetch_cost_rows and the tests that only exercise it, or move it into tests/test_report.py if they use it as setup

The first line is the check id and the definition; indented lines are the evidence and the fix. one-impl names the single implementation of an ABC or Protocol (maybe structural). const-param has a line per parameter: always <value>, or passed explicitly at N sites when callers write the default. pass-through forms: forward only calls a repo function, binding at most short literals or one constant; rename renames an outside call for 2 callers; single-use is a private one-liner with one call. dead-code: no production code mentions the name; tests: lists the test lines that do.

For each finding:

  1. Read the evidence. Is it a deliberate seam the counts cannot see (a plugin point, a public API, a knob a caller outside the repo turns)?
  2. If not, apply the suggest: line, then run the tests.
    • const-param: remove the parameter, use the value in the body, delete every branch the value makes dead, then update the calls.
    • pass-through: replace each listed call with the call the suggestion writes (forward, rename) or the body (single-use), then delete it.
    • dead-code: delete it; delete or rewrite the listed tests with it.
  3. If it is deliberate, suppress it with a reason on any line of its header (a decorator through the closing :): # dermestes: keep <reason>. Without a reason it still reports, with a keep: missing reason line.

Known misses (it stays silent, by design):

  • one-impl: a test implementation, an unresolvable base or subclass, or a plain base class (no ABC, no abstract methods) with one subclass.
  • Calls count only when resolved (f(), mod.f(), self.m(), Class()); one on a variable (tr.done()) that could bind silences the counters.
  • All are silent for functions used as values, decorated ones, overrides, overridden methods, methods of a class with a third-party base.
  • const-param: a variable or enum member is never “the same value”.
  • pass-through: any test caller vetoes it, as do a third-party target for a forward, a public wrapper of a private one, *args, a multi-statement body.
  • dead-code: callers that are themselves dead, a name only in prose or __all__, one shared with another method, public names in a py.typed package.
  • Syntax-error files are skipped. Diff mode ignores untracked files: git add -N.

dermestes guide tune lists the config keys that exempt whole paths.

next: run dermestes

Tune dermestes

All configuration lives in [tool.dermestes] in pyproject.toml.

[tool.dermestes]
exclude = ["migrations/**"]
test-paths = ["tests/**", "test_*.py", "*_test.py", "conftest.py"]
ignore-decorators = ["functools.cache"]
public = ["src/mypkg/api/**"]
disable = ["one-impl"]
  • exclude: files never indexed, for vendored or broken code. Their calls do not count. examples/**, docs/** and **/test/resources/** are different, whatever exclude says: indexed, so their calls vary a parameter and make a wrapper documented API, but never reported, since there the explicit parameter is the lesson.
  • test-paths: files whose code is counted as tests, never as targets.
  • ignore-decorators: decorators that no longer exempt a function from const-param, forward pass-through or dead-code, by dotted name or suffix (cache covers functools.cache).
  • public: globs treated as public API and exempt.
  • disable: check ids to skip.

Suppression is per definition, on any line of its header (decorators through the closing :), with a mandatory reason: # dermestes: keep <reason>.

Always exempt, whatever the config: test code, generated files (Do not edit or DO NOT EDIT in the first 5 lines), names in __all__, names re-exported from an __init__.py, dunder methods (const-param checks __init__), ABCs passed to X.register(...), and any name passed as a string to getattr, setattr, hasattr, patch or patch.object (a dotted path counts by its last part), or used as a string key in a registry dict ({"csv": CsvExporter}). Docstrings and string type annotations do not count.

All but one-impl also exempt decorated functions (unless ignore-decorators lists every decorator), functions used as a value anywhere (register(f), x.f without a call), overrides, abstract and Protocol methods, functions called only under if __name__ == "__main__":, and every function of a module or class getattr reads a computed name from. const-param also exempts constants a test patches; pass-through also exempts functions named in [project.scripts] or [project.entry-points] of any pyproject.toml, anything a test calls, and any private name a test spells as a whole string ("_load"). Suppress a deliberate wrapper with keep, or disable = ["pass-through"].

dead-code counts mentions, not calls: any identifier or attribute with the name, a string spelling it, or the word in any non-.py file (a template, TOML, Markdown), docs/ and examples/ included. It also exempts entry points, Alembic upgrade/downgrade, _repr_*_ hooks, and public names in a package that ships py.typed.

A library’s public functions are called from outside the repository. List their modules in public rather than keeping each one.

next: run dermestes --all