Enforce structural conventions in Python codebases
Reason this release was yanked:
Renamed to konpy — install "konpy" instead
Project description
konsistent-py
Enforce consistent code, for agents and humans.
Python port of vercel-labs/konsistent (distributed as konsistent-py; the import package and CLI are still konsistent): a CLI linter that checks whether files and directories in your Python codebase match declared structural conventions — project layout, required files, required module-level definitions/exports/imports, __init__.py re-export purity, docstring and annotation coverage, naming patterns, dead code — via a declarative konsistent.json.
konsistent is deliberately not a style linter or type checker. Formatting belongs to ruff, types to mypy. konsistent owns the layer above: "every src/{name}_service.py exports a ${name.toPascalCase()}Service class, has a paired tests/test_{name}.py, documents its public API, and never imports from the infrastructure layer."
Install & run
uv tool install --from /path/to/konsistent-python konsistent-py # or: uv add --dev konsistent-py
konsistent # = konsistent check, reads ./konsistent.json
konsistent validate # schema-check the config without scanning
konsistent check --config-path other.json --max-diagnostics 300
Quickstart
Create konsistent.json at the repo root:
{
"$schema": "./konsistent.schema.json",
"version": "v1",
"conventions": [
{
"name": "services-have-shape",
"description": "Every service module exports its class and has a test.",
"paths": "src/{name}_service.py",
"must": {
"exportClasses": ["${name.toPascalCase()}Service"],
"havePairedFile": "tests/test_${name}_service.py",
"haveDocstrings": { "publicOnly": true },
"annotateFunctions": { "publicOnly": true }
},
"mustNot": {
"matchContent": ["\\bFIXME\\b"],
"importFrom": "src.infrastructure"
}
}
]
}
paths globs capture placeholders ({name}) that predicates consume via ${name} templates with case transforms (toPascalCase, toSnakeCase, toConstantCase, …). Everything under must must hold; anything under mustNot must not. Full vocabulary: docs/reference/predicates.md; path grammar: docs/reference/path-patterns.md.
Notable predicates: haveFiles, haveType, export*/declare* families, import/importFrom/importFromParents, areBarrelFiles, useDeclarationOrder, and the coverage/content set: matchContent (regex on file content — the escape hatch), havePairedFile (repo-root-relative), haveDocstrings, annotateFunctions. Dead-code detection is configured separately via the top-level unusedCode key — next section.
Unused-code detection
unusedCode is a classifier, not a flagger — the design goal is zero noise. Instead of reporting every unreferenced name the way vulture or basedpyright's reportUnusedFunction do, it classifies every definition (module-level functions, classes, and constants, plus one level of class-body methods and attributes) and reports only the two actionable classes, staying silent on the systematic false-positive classes that framework code produces:
{
"version": "v1",
"conventions": [],
"unusedCode": {}
}
- Reported:
dead— no reference anywhere (code, tests, entrypoint files, string literals) — andtest-only— referenced only undertestGlobs, i.e. code kept alive purely by its own tests, a delete-with-its-tests candidate no comparable tool surfaces. - Silent: decorator-registered handlers (
@app.*,@field_validator,@pytest.fixture, …), lifecycle hooks and dunders, model fields onBaseModel/TypedDict/Enum/@dataclassclasses, and symbols named in entrypoint files (DockerfileCMD,pyproject.toml, serverless templates). A bare{}already understands pydantic, FastAPI/Flask/Django, pytest, celery, and click/typer via shipped presets.
References are resolved repo-wide, including identifier tokens inside string literals — "src.lambda_function.handler" in a Dockerfile keeps handler alive. Matching is by bare name and deliberately under-reports before it ever false-positives (see limitations).
Findings arrive as warning-severity diagnostics under the [unused-code] label (predicate unusedCode.dead / unusedCode.testOnly in --format json) — gate them in CI with --error-on-warnings, and suppress an approved exception with # konsistent: ignore[unused-code] (the consent policy applies). The config keys (include, testGlobs, entrypointFiles, registryDecorators, hookNames, modelBases) extend the presets; allow silences specific names by decision. Because classification needs the whole reference graph, unusedCode always scans the entire project — --files/--changed scoping never narrows or partially runs it. Full taxonomy, presets, and config keys: docs/reference/unused-code.md.
Reusable conventions & the best-practices pack
Rules can be packaged once and consumed everywhere. This repo ships a starter pack at packs/python-best-practices.json:
{
"version": "v1",
"conventionSources": { "bp": "./packs/python-best-practices.json" },
"conventions": [
"bp/init-files-are-barrels",
"bp/absolute-imports-only",
"bp/docstrings-on-public-api",
"bp/annotated-public-functions",
{ "use": "bp/paired-test-files", "paths": ["src/{name}.py", "!src/__init__.py"] },
{ "use": "bp/class-name-matches-filename", "paths": "src/{name}_service.py" }
]
}
String form uses the pack rule's own paths; use form supplies (or overrides) paths, placeholders, and severity. Authoring guide: docs/guides/authoring-reusable-conventions.md. Copy-paste templates for project-specific rules (layered import bans, DDD layouts, test-suite layout): docs/guides/templates.md.
More packs: hexagonal architecture and src layout
Two additional off-the-shelf packs live alongside the best-practices one:
packs/hexagonal-architecture.json— ports-and-adapters layering: domain modules stay free of adapter/infrastructure imports, ports areProtocol/ABCboundaries, adapters export an*Adapter-suffixed class, and each use case has a paired test. Assumessrc/domain/,src/ports/,src/adapters/,src/use_cases/.packs/src-layout.json—src/layout hygiene: the project root hassrc/+pyproject.toml, every top-levelsrc/package has an__init__.py, and both flat and one-level-nested modules mirror intotests/.
Consume either one the same way, via conventionSources:
{
"version": "v1",
"conventionSources": {
"hex": "./packs/hexagonal-architecture.json",
"layout": "./packs/src-layout.json"
},
"conventions": [
"hex/domain-does-not-import-adapters-or-infrastructure",
"hex/ports-are-protocols-or-abcs",
"hex/adapters-export-adapter-suffix",
"hex/use-cases-paired-with-tests",
"layout/project-root-uses-src-layout",
"layout/top-level-src-packages-have-init",
"layout/top-level-modules-mirror-into-tests",
"layout/nested-modules-mirror-into-tests"
]
}
Full per-convention reference, including the layout assumptions each pack makes: docs/reference/packs.md.
Distributing packs on PyPI
A convention source can be a bare Python distribution name. Ship a konsistent.json (reusable-package format) as package data, publish, and consumers write:
{ "conventionSources": { "acme": "acme-conventions" } }
after uv add --dev acme-conventions. konsistent resolves the installed distribution via importlib.metadata — no network at lint time.
Config inheritance (org base → team → project)
{
"version": "v1",
"extends": ["acme-base-config", "./team-overlay.json"],
"disable": ["legacy-rule"],
"conventions": [
{ "name": "rule-also-in-base", "paths": "packages/{name}", "must": { "haveFiles": ["README.md", "pyproject.toml"] } }
]
}
Parents load left-to-right (local paths or installed package names), then the child overlays: conventions concatenate, a same-name convention replaces the inherited one in place, disable removes inherited rules by name, everything else deep-merges. Cycles are detected and rejected. Details: docs/reference/configuration.md.
Custom rules
Three tiers, cheapest first:
-
matchContent— most "custom rules" are a regex away, no code required:{ "name": "no-naive-utcnow", "paths": "src/**/*.py", "mustNot": { "matchContent": ["\\butcnow\\(\\)"] } }
-
A reusable pack — bundle rules built from existing predicates into a
konsistent.jsonpackage (local file or PyPI) so every repo consumes the same definitions. -
Plugin predicates — real custom logic as Python code, loaded from entry points. In your plugin package:
[project.entry-points."konsistent.predicates"] requireMarker = "acme_konsistent.rules:require_marker"
from konsistent.plugin import PredicatePlugin, create_diagnostic def check(*, expected, context, structure, convention_name, severity): if expected not in context.file_system.read_file(context.path): return [create_diagnostic( file_path=context.path, predicate_name="requireMarker", message=f'Missing marker "{expected}"', convention_name=convention_name, severity=severity)] return [] require_marker = PredicatePlugin( key="requireMarker", value_model=str, handler=check, forbidden_message_template='Forbidden marker "{value}"')
Consumers must opt in explicitly — konsistent never executes code the config didn't name:
{ "version": "v1", "plugins": ["acme-konsistent"], "conventions": [{ "name": "markers", "paths": "src/*.py", "must": { "requireMarker": "PLUGIN_OK" } }] }
Plugin keys work under
mustNottoo, get strict value validation from the plugin's own pydantic model, and collide loudly with builtins. Full contract (AST access viauses_ast, item-level mustNot, placeholder validation): docs/reference/plugins.md.
Using konsistent with Claude Code (the agent loop)
konsistent was built for exactly one workflow: encode your conventions once in konsistent.json, then wire them into every stage of a coding agent's loop — before it writes, after each edit, and in CI — so the agent hears one consistent voice everywhere. The pieces below each have their own section; this is the order they compose in:
- Bootstrap the rules — don't hand-author them. Mine an existing codebase with
konsistent infer, translate a prose style guide or skill withextract-rules, or start greenfield from the shipped packs. All three emit reviewable proposals, never live config. - Prevention: put the rules in the agent's context.
konsistent explain >> CLAUDE.mdrenders the resolved config as agent guidance — the agent writes conformant code on the first pass instead of getting caught afterwards. Re-run it when the config changes. - Per-edit feedback: a
PostToolUsehook. Runkonsistent check --files <edited-file>after everyEdit/Write; on violation the hook exits2and the JSON diagnostics land in front of the agent, which fixes them in the same session — withexpected/found/fixHintso the fix needn't be re-derived from a message string. Recipe: docs/guides/claude-code-hook.md. For judgment calls no structural predicate can express, layer the agentickonsistent hookon top (this repo does, via its own.claude/settings.json). - Keep exceptions honest. Suppressions require a named rule and a human decision — the consent policy is restated in every
explainrender, so the agent knows it. - Gate and measure.
konsistent check --error-on-warningsin CI, and the eval harness to snapshot violation metrics before/after an agent session and fail on regression.
Division of labor: ruff owns universal style and correctness; konsistent owns your architecture — layout, naming-to-export contracts, import boundaries, paired tests, dead code. They complement, not compete.
Extracting rules from skills & style guides
Turn prose best practices — a Claude Code skill's SKILL.md, a team style guide, any markdown — into a reviewable pack:
konsistent extract-rules .agents/skills/python-project-structure/SKILL.md
konsistent extract-rules style-guide.md -o packs/team-style.json --agent codex --report unmapped.md
It shells out to a local agent CLI (claude -p or codex exec; --agent auto is the default and prefers claude), pins the agent's model via --model (default: sonnet, forwarded as the agent CLI's own --model flag — pass an explicit value for codex), embeds the predicate vocabulary and pack format in the prompt, and validates the result against the pack schema before writing anything. Rules that aren't structurally expressible are never silently dropped — they land in an unmapped-rules report with reasons (that's your ruff/mypy/plugin backlog). The output is a proposal for human review; extract-rules never edits konsistent.json. Guide: docs/guides/extracting-rules.md.
Explaining rules to an agent
konsistent explain renders your fully resolved konsistent.json (after extends/disable/conventionSources/plugins) as concise Markdown or plain-text guidance — one bullet per convention with its name, paths, description, hint, and severity — so you can paste it into CLAUDE.md and have a code-writing agent follow the rules before writing code, not just get caught by check afterwards:
konsistent explain > CLAUDE.md
konsistent explain --format text
It is read-only: no filesystem scan, no diagnostics, no --fix. Every render ends with a standing reminder of the suppression consent policy — agents must never add a # konsistent: ignore[...] comment without explicit human approval.
Mining a codebase for conventions
konsistent infer scans an existing codebase for statistical regularities — "94% of modules under adapters/ export *Adapter; here are the 3 violators" — and proposes a reviewable ReusableConventionsPackageV1-shaped pack (the same output contract as extract-rules: {"conventionSpecVersion": "v1", "conventions": [...]}, never a konsistent.json-shaped document) plus a confidence/violators report, using six deterministic heuristics (no agent call):
konsistent infer > konsistent.infer.pack.json
konsistent infer --heuristic export-suffix --heuristic paired-test-file -o proposal.json -r report.md
The proposed pack goes to stdout (or --output); the confidence/violators report goes to stderr (or --report). Every proposal is severity: "warning" and carries support/total counts plus a violator list — infer never edits konsistent.json and never reads an existing one. Guide: docs/guides/inferring-conventions.md.
Agentic verification hooks
konsistent hook wires an agentic verifier into Claude Code's or Codex's PostToolUse hooks: after a matched write/edit, it spawns a read-only verifier agent (claude -p or codex exec) with a natural-language --prompt and turns its pass/fail verdict into the hook exit-code contract (0 pass/skip, 2 fail, 1 infra fail-open), with sentinel-based recursion guards so the verifier can't re-trigger the hook that spawned it.
konsistent hook --agent claude --model sonnet --match 'src/**/*.py' --prompt 'Docstrings are not aspirational: verify each function body actually does what its docstring claims.'
The verifier's model is pinned via --model (default: sonnet, forwarded to the agent CLI as its own --model flag; set it explicitly for codex). Exit 2 is reserved exclusively for a real fail verdict: every misconfiguration — missing --prompt/--agent, an invalid --agent value, even an option the installed konsistent doesn't recognize — fails open with exit 1 rather than surfacing a CLI usage error to the host agent as if it were review feedback.
This is not the same mechanism as the --files diff-scoped hook recipe below — that one runs konsistent check directly (deterministic, no LLM call, verifies konsistent.json structural conventions). konsistent hook is for checks a structural predicate can't express; use it for the subset of your review that needs judgment, and the deterministic recipe for everything else. This repo dogfoods the agentic hook via its own .claude/settings.json. Guide: docs/guides/hooks.md.
CLI
| Command | Purpose |
|---|---|
konsistent / konsistent check |
scan and report violations (exit 1 on errors) |
konsistent validate |
validate the config only |
konsistent explain |
render resolved conventions as agent guidance (see above) |
konsistent extract-rules <src> |
agent-assisted rule extraction (see above) |
konsistent infer |
mine the codebase for candidate conventions (see above) |
konsistent hook |
agentic PostToolUse verification hook (see above) |
konsistent version |
print version |
Useful flags: --config-path, --placeholder name:value, --max-diagnostics, --format json, --show-suppressed. Full reference: docs/reference/cli.md.
Diff-scoped checking (--files / --changed)
Scope a run to files an agent just touched — konsistent check --files src/service.py or konsistent check --changed (tracked changes since HEAD plus untracked files, per git diff/git ls-files). --changed requires a git repository: outside one it prints a single clear message to stderr (not a raw git error dump) and exits 1 — it never silently falls back to a full scan. Scoping is convention-level, not file-level: a convention is selected as soon as any file in scope falls in its matched set, and then it's evaluated over its entire matched set — so a violation on a sibling file the agent didn't touch can still surface. havePairedFile and unusedCode are whole-graph predicates; see Full semantics and edge cases below for exactly how each is handled under scoping.
konsistent check --files src/service.py
konsistent check --changed
This is what powers the Claude Code PostToolUse hook recipe: run konsistent check --files <edited-file> --format json after every Edit/Write so an agent gets scoped, structured feedback on the file it just changed without waiting for a full check. Full semantics and edge cases: docs/reference/cli.md#diff-scoped-checking---files----changed.
Diagnostic intent and fix direction
Diagnostics can carry more than a message: an optional convention-level description/hint (inherited by every diagnostic the convention produces) and predicate-specific expected/found/fix_hint fields, so an agent's next edit doesn't need to be re-derived from a message string.
{
"name": "documented-service",
"description": "Service modules must be paired and documented.",
"hint": "Run the service generator template if you are starting a new service.",
"paths": "src/service.py",
"must": { "havePairedFile": "tests/test_service.py" }
}
{
"predicateName": "havePairedFile",
"message": "Missing paired file: tests/test_service.py",
"description": "Service modules must be paired and documented.",
"expected": "tests/test_service.py",
"fixHint": "Create the paired file at \"tests/test_service.py\"."
}
All five fields are optional and additive: omitted from --format json when absent (never null), and shown as an extra suffix line/cell in default/markdown output only when populated. expected/found/fix_hint are currently populated by exportClasses, exportConstants, havePairedFile, haveDocstrings, annotateFunctions, importFrom, the importFrom*/importTypes* group predicates, and matchContent — other predicates leave them unset rather than guessing. fix_hint is data only; konsistent never applies it automatically. Full reference: docs/reference/cli.md#diagnostic-intent-and-fix-direction.
Suppressions
Approved exceptions can be silenced in place, without touching konsistent.json:
# konsistent: ignore-file[max-module-length] -- splitting tracked in TICKET-123
"""Module docstring."""
def orphaned(): # konsistent: ignore[docstrings-on-public-api, unused-code] -- approved legacy hook
...
- Line-level
# konsistent: ignore[rule-a, rule-b]on the flagged line (or the line directly above) suppresses those rules' findings anchored to that line. - File-level
# konsistent: ignore-file[rule-name]must appear before the first code line and also covers findings with no line anchor (matchContent,havePairedFile,haveType,importFrom). - The bracketed rule list is mandatory — there is no blanket ignore. Names match the
[bracket]label shown in check output. An optional reason follows--.
Suppressions are designed to never be invisible:
- Every summary shows the count:
Found 1 error. Suppressed 3 findings. konsistent check --show-suppressedlists each suppressed finding with its reason;--format jsonalways includes the fullsuppressedarray.- Stale or unknown suppressions are themselves reported as warnings (
Unused suppression for "rule-name"), so dead ignores get cleaned up — and they fail CI under--error-on-warnings. - Suppressed errors don't fail the build; the exit code counts only unsuppressed findings.
Policy for AI coding agents: never add a suppression comment without explicit human approval. The correct default is to fix the violation or ask for a decision. When approval is granted, use the narrowest form (line-level over ignore-file) and always include the reason. Full grammar and semantics: docs/reference/suppressions.md.
Agent evaluation
scripts/eval_conventions.py A/B-compares how much structural drift a coding agent introduces or removes under different guidance strategies, using konsistent's own diagnostics as the metric. It runs konsistent check --format json against a target repo and reduces the result into a stable, diffable metrics summary, so you can snapshot a repo before and after an agent run and diff the two:
uv run python scripts/eval_conventions.py run /path/to/target-repo --label before --output before.json
# ...agent does its work...
uv run python scripts/eval_conventions.py run /path/to/target-repo --label after --output after.json
uv run python scripts/eval_conventions.py compare before.json after.json
The comparison reports files checked, total diagnostics, errors/warnings/suppressed, and per-convention/per-predicate/unusedCode breakdowns as before -> after (delta), plus a PASS/FAIL - errors increased regression check (compare --fail-on-regression turns that into a nonzero CI exit code). It is a repo-dev script — stdlib-only, shells out to the konsistent CLI as a subprocess rather than importing the package — meant for pairing with konsistent explain guidance and the --files hook to measure their effect on an agent's output. Guide: docs/guides/agent-eval.md.
Development
uv run pytest # full suite
uv run ruff check .
uv run konsistent # the repo lints itself
uv run python scripts/generate_schema.py # regenerate konsistent.schema.json after schema changes
Docs index: docs/README.md. The TypeScript original lives in tmp/konsistent as a read-only reference; the v1 config grammar is kept compatible (all Python-port additions — extends, disable, plugins, the coverage predicates — are optional keys).
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file konsistent_py-0.1.0.tar.gz.
File metadata
- Download URL: konsistent_py-0.1.0.tar.gz
- Upload date:
- Size: 360.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8219baac2767127552c1155e088b17e5176c85687201d053b86ed056cc8b2b5a
|
|
| MD5 |
6da804761bf28bf40ae33412c7f9ffbf
|
|
| BLAKE2b-256 |
9bcc8248f6adaacdb1999b02520b647c7d59fc6cb9b2edb0d4968020cd780be9
|
Provenance
The following attestation bundles were made for konsistent_py-0.1.0.tar.gz:
Publisher:
publish.yml on ivorpad/konsistent-python
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
konsistent_py-0.1.0.tar.gz -
Subject digest:
8219baac2767127552c1155e088b17e5176c85687201d053b86ed056cc8b2b5a - Sigstore transparency entry: 2059780911
- Sigstore integration time:
-
Permalink:
ivorpad/konsistent-python@e21970bbd92186698ef65947ac5f541788b1d662 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/ivorpad
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@e21970bbd92186698ef65947ac5f541788b1d662 -
Trigger Event:
push
-
Statement type:
File details
Details for the file konsistent_py-0.1.0-py3-none-any.whl.
File metadata
- Download URL: konsistent_py-0.1.0-py3-none-any.whl
- Upload date:
- Size: 189.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
46671fce13682f6e0cb28aebda23ff12c788ec7bd79bdc658cb05b6047d2b3a1
|
|
| MD5 |
a5b87f09da3c6c91501433061a17149b
|
|
| BLAKE2b-256 |
1059cf6c30c7bef1a04a1cadbd17451b3ab6be5760b23252adca3dc0c8acf3be
|
Provenance
The following attestation bundles were made for konsistent_py-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on ivorpad/konsistent-python
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
konsistent_py-0.1.0-py3-none-any.whl -
Subject digest:
46671fce13682f6e0cb28aebda23ff12c788ec7bd79bdc658cb05b6047d2b3a1 - Sigstore transparency entry: 2059781322
- Sigstore integration time:
-
Permalink:
ivorpad/konsistent-python@e21970bbd92186698ef65947ac5f541788b1d662 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/ivorpad
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@e21970bbd92186698ef65947ac5f541788b1d662 -
Trigger Event:
push
-
Statement type: