A linter knows the language; it does not know your project. It will not tell
you that an ORM model has wandered into a use case, that a Numeric column
was left without a CHECK, that datetime.now() is called in the domain rather
than behind a port. These are not language errors — they are broken
conventions, and until now review caught them: by eye, differently for each
reviewer, from scratch every time.
py-checks is an engine for conventions like these. The library brings the
rules, the project brings its architecture: the names of the layers, the list
of sealed zones, where the ORM lives, what bounds a column. Without the
project's tables the rules stay silent — the library does not guess what your
domain is called.
src/app/modules/cashout/application/offer.py:34:9: determinism: uuid4() is not deterministic;
the identifier is handed out at the edge and passed inwards
src/app/infra/database/models/bet.py:51:5: model-columns: stake is a Numeric with no bound;
money is described by Numeric(18, 4)
src/app/presentation/api/v1/routers/bets.py:22:1: edge-declarations: POST /bets
did not name a response_model
Why
- A convention stops being spoken. The rule is written once, with its reason, and checked on every commit — rather than recalled at review by whoever remembers it.
- The rule is universal, the table is yours. One rule, "this call lives
only here", covers both the transaction boundary and the ban on
floatin the domain. The library contains not one name from your project. - A refusal explains itself. The message says what is wrong and what to write instead, not "violation of rule #14".
- An exemption costs one line and owes a reason.
# check-ok: raw-sql: a liveness probe, there is no ORM form of it— a mark without a reason is itself a violation. - We do not do other people's work. What ruff, pyright and import-linter
can do stays theirs; what was handed over, and why, is written down in
docs/service.md.
Install
uv add --dev python-checks
Installed as python-checks, called as py-checks: a neighbour holds the
short name on PyPI, while the command, the settings section and the package
stayed as they were.
Needs Python 3.14+. Dependencies: libcst, pydantic, pydantic-settings,
rich, typer.
In a minute
Put a py-checks.toml next to pyproject.toml:
src = "src"
[module-length]
max-lines = 300
# Rules that depend on place only work in the zones you name.
[model-columns]
zones = ["infra/database/models"]
instead = { Float = "a Float column drifts; state is exact, use Numeric" }
[determinism]
zones = ["modules/*/domain", "modules/*/application"]
instead = { "datetime.now" = "take the clock as a port", "uuid4" = "hand the id out at the edge" }
and run:
py-checks run # check `src`
py-checks run --fix # and repair what repairs itself
py-checks list # which rules exist and which are on
py-checks explain determinism # what a rule asks for and what it can be told
py-checks doctor # and whether the config itself holds together
The settings can also live in a [tool.py-checks] section of
pyproject.toml — but one of the two: two places at once is an error to this
library, not a merge.
What is checked
Twenty-eight rules in nine groups:
| Group | About |
|---|---|
imports |
which package is allowed where, which zone is sealed |
placement |
what belongs in this directory and how an operation is shaped |
signatures |
module length, nesting depth, the shape of a signature |
types |
bounds on fields, the shape of an annotation, immutability |
database |
the model's boundary, a column's material, the shape of a query, the schema |
effects |
the clock, the dice, and the name of an event in the log |
api |
what an entrance declares about itself |
errors |
the code a refusal carries |
calls |
a function whose call sites can be listed |
hygiene |
a ceiling on a dependency |
All twenty-eight
| Code | What fails | Scope |
|---|---|---|
confined-imports |
a package is imported outside the places set aside for it | file |
sealed-imports |
a sealed zone imports a foreign package | file |
class-modules |
a module holds what its directory does not allow | file |
class-placement |
a class lies somewhere other than where its kind lives | file |
operation-shape |
an operation is not shaped like an operation | file |
required-class |
a module did not declare the class its directory exists for | file |
keyword-only-arguments |
a signature is not written out in full | file |
function-length |
a function is longer than the limit | file |
module-length |
a module is longer than the limit | file |
nesting |
control structures are nested deeper than the limit | file |
signature-layout |
a list of two or more entries is written on one line | file |
annotation-shapes |
a shape is named such that its fields have no names | file |
config-fields |
a settings field carries no bound | file |
confined-types |
a field in this part of the tree is declared with a type banned here | file |
constant-annotations |
a constant did not say by its type that it is one | file |
frozen-dataclasses |
a dataclass in a zone is declared without the required arguments | file |
bound-checks |
a bounded column did not restate its bound as a CHECK | file |
confined-calls |
a named method was called somewhere it does not belong | file |
model-boundary |
an ORM model is declared, built or handed out in the wrong place | file |
model-columns |
a column is built out of the wrong material | file |
raw-sql |
SQL is written as a string where an expression would do | file |
statement-keys |
a statement names a column by string, or goes to the database in a loop | file |
schema-drift |
the models and the migrations describe different schemas | environment |
determinism |
the code reads the clock, the dice or a new identifier itself | file |
log-events |
an event in the log is named by something other than an enum member | file |
edge-declarations |
an entrance did not say how it behaves | file |
refusals |
a no nobody can branch on | file |
confined-functions |
a named function was called from somewhere it may not be | file |
dependency-bounds |
a dependency may move to a version nobody has ever run | project |
Every rule explains itself in full — py-checks explain <code> prints the
docstring with the reason and the list of settings. A worked set of tables for
a typical service is in
docs/service.md.
Three kinds of rule
What a rule is handed to judge, the rule declares itself, in its scope:
- file — a parsed source file; most rules are these;
- project — the root: the manifest, the agreement of the repository's files with one another;
- environment — the same, but it needs a live database or a long run. An ordinary run leaves these out:
py-checks run --all, or by name; their place is CI.
Marks
A line can be taken out of a rule's judgement, but you have to say why:
text("SELECT 1") # db-ok: raw-sql: a liveness probe, there is no ORM form of it
The canonical form is # check-ok: <code>: <reason>, and it lifts exactly one
rule. Every group has a short word of its own (# db-ok, # type-ok,
# signature-ok, …): a person remembers the group, not twenty-eight codes.
A line may carry several marks — a signature written in a column gathers them on its last line:
) -> object: # signature-ok: this is how pydantic calls it # type-ok: raw input
A mark with no code, with a typo in the code, or with no reason is a violation itself. A silently dead mark looks like a disabled check, while the check is in fact running and simply does not see it.
pre-commit
- repo: https://github.com/Armontex/py-checks
rev: v0.3.1
hooks:
- id: py-checks
args: [--fix] # repair what repairs itself
- id: py-checks-sync # contracts and `.env.example` rebuilt
One hook rather than one per rule: which rules run is the config's decision. A
set of rules is called with args: [--select, "<code>,<code>"]; a repeated
flag does the same.
It has to stand before ruff-format: the autofix writes characters, not
columns, and the project's formatter lays the signature out.
Everything else — ruff, pyright, import-linter, commitizen — the project declares itself: each has a hook written by its own authors.
Import contracts
The project's layers are described once, and .importlinter is built from
that:
[contracts.layers]
domain = ["domain"]
application = ["application", "domain"]
presentation = ["presentation", "application"]
py-checks sync # build
py-checks sync --check # fail if the file has fallen behind
It is built not only from the table but from what is on disk: a layer that does not exist is left out of the contract — otherwise import-linter would fail on the first module that is not there. The graph itself is walked by import-linter's own hook.
The header of the built file can be your own — header in [contracts],
# included; as with .env.example, it is for a project whose comments are
in another language.
The environment example
The name of a variable is known to the settings field — it declares it as
validation_alias, and config-fields watches for that. So .env.example is
derived from the same classes the variables are read into, and there is no
reason to keep it by hand: it falls out of step silently, and that is noticed
when the variable turns out to be missing in production.
[env-example]
settings = ["myservice.config.settings:Settings"]
The same py-checks sync builds it alongside the contracts. The root class is
enough: it has already listed its sections as its own fields, and repeating
that list in the settings would mean keeping a second one that drifts from the
first. A section field does not become a variable — it has no name of its own
in the environment. What goes into the file is the variable's name, its
default, the first paragraph of the class docstring and the field's
description if it has one — so the explanation lives next to the field
rather than in a file that outlives it.
The header can be written by the project as well — header = "# Generated by py-checks.", # included. It is for a project whose comments are in another
language; an empty one keeps the library's.
The classes are imported rather than read as text: the variable's name is an attribute's value, assembled by a call, and reading it from the source would mean performing that call yourself.
A rule of your own
A rule is a class with four fields and a run, declared through entry points.
There is no need to fork the library:
# myproject_checks/_no_print.py
import ast
from collections.abc import Iterator
from typing import ClassVar, Final
from py_checks.config import CheckSettings
from py_checks.core import ParsedFile, Scope, Violation
CODE: Final = "no-print"
class NoPrint:
"""Fails if a `print` was left in the source."""
code: ClassVar[str] = CODE
Settings: ClassVar[type[CheckSettings]] = CheckSettings
scope: ClassVar[Scope] = Scope.FILE
marker: ClassVar[str] = "# my-ok"
@classmethod
def run(cls, *, file: ParsedFile, settings: CheckSettings) -> Iterator[Violation]:
for node in ast.walk(file.tree):
if isinstance(node, ast.Call) and getattr(node.func, "id", "") == "print":
yield Violation.from_node(
node=node,
path=file.path,
code=CODE,
message="a print in the source; an event is written to the log",
)
[project.entry-points."py_checks.checks"]
no-print = "myproject_checks:NoPrint"
From there it behaves like a native one: it appears in list and explain,
obeys ignore, and is lifted by a mark.
Commands
| Command | |
|---|---|
py-checks run [paths] |
run the checks; 0 — clean, 1 — violations found |
py-checks run --fix |
apply the repairs that are unambiguous |
py-checks run --select <code>,<code> |
only the named rules, ignore notwithstanding |
py-checks run --all |
including the rules that need a live environment |
py-checks list |
every rule: code, state, one line of description |
py-checks explain <code> |
what a rule asks for and what it can be told |
py-checks sync [--check] |
build the import contracts and .env.example |
py-checks doctor |
check the config itself: typos, dead addresses, rules that say nothing |
py-checks mutation diff|full|record |
the mutation gate over mutmut; needs python-checks[mutation] |
Development
uv sync
uv run pre-commit install
uv run pytest -q
A check is described by folders of examples rather than by a test:
tests/checks/<code>/ with ok/ and bad/ inside, and syrupy compares the
snapshot of the output. Project rules have tests/projects/<code>__<variant>/.
The library checks itself: a rule that cannot survive its own repository has
no business reaching anybody else's.
The repository's own conventions are in
AGENTS.md.
License
MIT — © 2026 Armontex.
Release files for python-checks 0.9.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| python_checks-0.9.0.tar.gz | 99.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| python_checks-0.9.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 256.1 kB
Release files / python_checks-0.9.0.tar.gz
| Download URL | python_checks-0.9.0.tar.gz |
|---|---|
| Size | 99.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
845a5d267d7d4dfd6b272dd6ae37f21882079823df817cdb40b29abc2ab0cd70
|
|
BLAKE2b-256 checksum How to use checksums |
bd29967cc118f3c518c305c8f1e3cbb94f36b695b1d621d9d1a9f95501953743
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.
Transparency logRelease files / python_checks-0.9.0-py3-none-any.whl
| Download URL | python_checks-0.9.0-py3-none-any.whl |
|---|---|
| Size | 156.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c42401b72b50e938a1b0618082865fc4fddb6ec4dd28a443a13204a1e109a8aa
|
|
BLAKE2b-256 checksum How to use checksums |
402f8acb6ef41b22dd0252b734c3695a52b02cfd5e1b004b5a2d8bd9530fe9a2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.
Transparency log