Skip to main content

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) — and test-only — referenced only under testGlobs, 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 on BaseModel/TypedDict/Enum/@dataclass classes, and symbols named in entrypoint files (Dockerfile CMD, 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 are Protocol/ABC boundaries, adapters export an *Adapter-suffixed class, and each use case has a paired test. Assumes src/domain/, src/ports/, src/adapters/, src/use_cases/.
  • packs/src-layout.jsonsrc/ layout hygiene: the project root has src/ + pyproject.toml, every top-level src/ package has an __init__.py, and both flat and one-level-nested modules mirror into tests/.

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:

  1. matchContent — most "custom rules" are a regex away, no code required:

    { "name": "no-naive-utcnow", "paths": "src/**/*.py",
      "mustNot": { "matchContent": ["\\butcnow\\(\\)"] } }
    
  2. A reusable pack — bundle rules built from existing predicates into a konsistent.json package (local file or PyPI) so every repo consumes the same definitions.

  3. 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 mustNot too, get strict value validation from the plugin's own pydantic model, and collide loudly with builtins. Full contract (AST access via uses_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:

  1. Bootstrap the rules — don't hand-author them. Mine an existing codebase with konsistent infer, translate a prose style guide or skill with extract-rules, or start greenfield from the shipped packs. All three emit reviewable proposals, never live config.
  2. Prevention: put the rules in the agent's context. konsistent explain >> CLAUDE.md renders 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.
  3. Per-edit feedback: a PostToolUse hook. Run konsistent check --files <edited-file> after every Edit/Write; on violation the hook exits 2 and the JSON diagnostics land in front of the agent, which fixes them in the same session — with expected/found/fixHint so 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 agentic konsistent hook on top (this repo does, via its own .claude/settings.json).
  4. Keep exceptions honest. Suppressions require a named rule and a human decision — the consent policy is restated in every explain render, so the agent knows it.
  5. Gate and measure. konsistent check --error-on-warnings in 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-suppressed lists each suppressed finding with its reason; --format json always includes the full suppressed array.
  • 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

konsistent_py-0.1.0.tar.gz (360.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

konsistent_py-0.1.0-py3-none-any.whl (189.5 kB view details)

Uploaded Python 3

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

Hashes for konsistent_py-0.1.0.tar.gz
Algorithm Hash digest
SHA256 8219baac2767127552c1155e088b17e5176c85687201d053b86ed056cc8b2b5a
MD5 6da804761bf28bf40ae33412c7f9ffbf
BLAKE2b-256 9bcc8248f6adaacdb1999b02520b647c7d59fc6cb9b2edb0d4968020cd780be9

See more details on using hashes here.

Provenance

The following attestation bundles were made for konsistent_py-0.1.0.tar.gz:

Publisher: publish.yml on ivorpad/konsistent-python

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for konsistent_py-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 46671fce13682f6e0cb28aebda23ff12c788ec7bd79bdc658cb05b6047d2b3a1
MD5 a5b87f09da3c6c91501433061a17149b
BLAKE2b-256 1059cf6c30c7bef1a04a1cadbd17451b3ab6be5760b23252adca3dc0c8acf3be

See more details on using hashes here.

Provenance

The following attestation bundles were made for konsistent_py-0.1.0-py3-none-any.whl:

Publisher: publish.yml on ivorpad/konsistent-python

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page