sarj-python-lint
Custom Python lint rules via stdlib ast. Designed for pre-commit. For SQL rules see sarj-sql-lint.
uv tool install sarj-python-lint
Pre-commit
- repo: https://github.com/sarj-ai/standards
rev: python-v0.24.0
hooks:
- id: sarj-no-sequential-await
- id: sarj-inefficient-string-concat-in-loop
- id: sarj-prefer-str-enum
- id: sarj-no-fat-try-blocks
- id: sarj-pydantic-at-boundaries
- id: sarj-prefer-class-row
- id: sarj-prefer-timedelta-for-durations
- id: sarj-prefer-struct-over-namedtuple
- id: sarj-no-comment-cruft
- id: sarj-no-fstring-in-log
- id: sarj-prefer-non-nullable-collection # SARJ074
Test-quality rules (0.15.0)
Mined from an AST audit of ~7,500 test functions across two production repos. Every one is scoped to test files and carries the false-positive guard that made it shippable; the module docstring for each records the population it was measured against.
- id: sarj-mock-without-spec # SARJ040
- id: sarj-test-loops-over-literal-cases # SARJ041
- id: sarj-parametrize-case-needs-id # SARJ042
- id: sarj-zero-assertion-test # SARJ043
- id: sarj-fixture-returns-bare-tuple # SARJ044
- id: sarj-kwarg-heavy-construction-in-test # SARJ045
- id: sarj-xfail-requires-strict # SARJ046
- id: sarj-sleep-with-computed-arg-in-test # SARJ047
Private access, first-party only (0.19.0)
- id: sarj-no-first-party-private-import # SARJ048
Reaching past a module's public surface is a design finding when the module is ours and an unavoidable fact of life when it is not: a dependency that moves an API private in a minor release leaves no edit that satisfies the lint.
SARJ048 fires only when the module declaring the private name resolves to a
package inside your own project. Third-party privates are never flagged.
It replaces ruff's PLC2701 import-private-name, whose only exemption is
same top-level package — a different question, and one that cannot separate
from bulbul.stores.task_store import _row_to_task (real; export it) from
from livekit.agents.inference_runner import _InferenceRunner (no fix exists).
sarj-lint-configs ≥ 0.8.0 ships PLC2701 in its ignore list for exactly this
reason; if you take that config, turn this hook on, or you lose the check
entirely.
Attribute access (session._stt) is out of scope and stays with ruff's
SLF001, which cannot make the distinction either — see the rationale in
ruff.strict.toml.
Comment-hygiene rules (0.20.0)
From a 37,918-comment, nine-repo measurement study. All three are deletion-class, so each was validated against pydantic / trio / attrs as well as the maintained repos before shipping — the counts and the false-positive classes each guard was built from are recorded in the rule module docstrings.
- id: sarj-no-restated-comment # SARJ049
- id: sarj-redundant-docstring # SARJ050
- id: sarj-trailing-value-narration # SARJ051
redundant-docstring finds real volume on a codebase that has never had it
(105 in noura-be), so the same baseline ratchet applies.
House conventions moved out of consumer repos (0.21.0)
- id: sarj-no-stdlib-logging # SARJ052
- id: sarj-no-gen-random-uuid-in-sql # SARJ053
- id: sarj-no-file-level-escape-hatch-noqa # SARJ054
SARJ052 bans importing stdlib logging in application code, because the
house logger is loguru and two logger hierarchies mean two handler chains: the
records written to the one nobody configured skip the JSON formatter, the
redaction patcher and the error reporter, and — since the stdlib root defaults
to WARNING — usually vanish in production while looking fine locally.
The one legitimate reason to touch stdlib logging in a loguru house is to
bridge it, and the bridge cannot be written without naming both loggers, so a
module importing loguru is exempt. Measured across two production repos that
exemption is exact: all four sites that import stdlib logging
(bulbul/__init__.py, bulbul/configure_logging.py, agent/main.py,
noura-be's common/logging.py) are bridges, all four import loguru, and no
other module in either repo imports stdlib logging at all. Tests, scripts/,
notebooks/, generated files and if TYPE_CHECKING: imports are also exempt.
This is a house-convention rule, not a universal one. A library should log
through stdlib logging precisely so it does not impose a sink on its callers —
trio's three sites are correct for trio. Enable it in applications only.
SARJ053 flags gen_random_uuid() in SQL embedded in a Python string literal:
UUIDv4 keys scatter B-tree inserts across every leaf page, where uuidv7()
(Postgres 18) is time-ordered and appends. It is the embedded-SQL third of a
policy the stack already states twice — ruff.strict.toml bans uuid.uuid4,
and sarj-sql-lint's SARJ109 prefer-uuidv7-default covers .sql migration
files (41 sites in bulbul, 14 in noura-be, all of them a primary-key DEFAULT).
A literal only counts when it is SQL-shaped, so prose naming the function is not
a finding.
SARJ054 is SARJ038's scoped sibling. SARJ038 bans the unscoped blanket
(# ruff: noqa); this bans a scoped file-level exemption that names an
escape-hatch code — a code whose remediation ruff.strict.toml spells as an
inline # noqa: CODE — <reason>, which today is TID251 alone, ruff's only
banned-API code. Hoisting that to the top of a file turns N reviewed per-site
decisions into one unreviewable one and pre-authorizes every mock added later.
Scoped exemptions for mechanical codes (E501, F401, UP035) are never
flagged — measured across five repos those are the entire population.
Mock-quality and real-dependency rules (0.24.0)
The second test-quality wave. Where the 0.15.0 family asks "does this test assert anything?", this one asks "does it exercise anything real?" — it pushes suites off hand-rolled doubles and onto the real store, the real database and a maintained fake library. Measured against bulbul, noura-be, five other first-party repos and fourteen OSS corpora; two candidates were dropped outright when the corpus showed they only duplicated ruff.
- id: sarj-prefer-real-store-in-tests # SARJ058
- id: sarj-prefer-library-fake # SARJ059
- id: sarj-tautological-mock-assertion # SARJ060
- id: sarj-no-patching-system-under-test # SARJ061
- id: sarj-over-mocked-test # SARJ062
- id: sarj-interaction-only-test # SARJ063
- id: sarj-trivially-true-assertion # SARJ064
- id: sarj-conditional-assertion-in-test # SARJ065
- id: sarj-duplicate-test-body # SARJ066
- id: sarj-unused-mock-setup # SARJ067
Expressiveness rules (0.24.0)
- id: sarj-prefer-fstring-over-concat # SARJ068
- id: sarj-prefer-match-pattern-destructuring # SARJ069
- id: sarj-prefer-or-pattern # SARJ070
- id: sarj-require-port-for-service # SARJ071
Suppression ratchet (sarj-ratchet, 0.21.0)
- id: sarj-suppression-ratchet
One tool replacing the per-repo ratchet scripts. It counts every escape hatch in the tree and enforces three ceilings that may only shrink:
- per code —
noqa:TID251going 40 → 41 is a regression even if the total falls - per package — one package's headroom must not finance another's debt
- per file — a global cap so new suppressions cannot pile into one hot spot; pre-existing hot spots are grandfathered at their then-current counts
All four dialects are counted under distinct key prefixes, so moving a
suppression between spellings can never hide it: noqa:CODE,
sarj-noqa:CODE, pyright:CODE, type-ignore:CODE / bare type-ignore, plus
the file-level file-noqa:CODE / file-noqa:<blanket> and file-pyright:RULE.
sarj-ratchet --update python/ # seed (or lock in a drop)
sarj-ratchet python/ # gate
sarj-ratchet --update --allow-increase python/ # a reviewed ceiling raise
--update refuses to raise a ceiling unless --allow-increase says the
raise was reviewed, and it drops a per-file grandfather clause as soon as the
file falls back under the global cap, so an allowance cannot outlive its debt.
Two conventions that stayed pygrep
sarj-fakes-in-shared-location and sarj-no-raw-connection-in-tests ship as
pygrep hooks, not SARJ rules, and both need a files:/exclude: from the
consumer. An AST port of each was built and measured, and the boundary each
encodes turned out to be repo-specific rather than shared: "shared fake" flagged
9/9 single-use test doubles in noura-be that are idiomatic where they sit, and
"raw connection in a test" flagged 46 sites in bulbul of which every one is
already an intentional exemption (store tests asserting DB state, pool-lifecycle
tests, retention tests where physical deletion is the subject). SARJ036
no-raw-sql-in-tests remains the corpus-validated shared rule for raw SQL in
tests.
Adopting these against an existing suite is easier through the baseline ratchet than as a big-bang fix — snapshot the current counts, then let them only shrink:
sarj-python-lint check --rule mock-without-spec --update-baseline test-quality-baseline.json python/
sarj-python-lint check --rule mock-without-spec --baseline test-quality-baseline.json python/
Multi-tenant scoping (0.24.0)
- id: sarj-no-optional-tenant-predicate # SARJ056
SARJ056 fires when every WHERE-fragment mentioning a tenant column
(organization_id and friends) sits inside a conditional, so the predicate
disappears — and the query still runs — whenever the filter is empty:
where_conditions = []
if args.organization_ids: # ← optional
where_conditions.append(SQL("organization_id = ANY(%s::uuid[])"))
...
where_clause = SQL(" AND ").join(where_conditions) if where_conditions else SQL("1=1")
The safe idiom seeds the list, so scoping always applies and the rule stays quiet:
conditions: list[Composable] = [SQL("organization_id = %s")]
A function with no tenant predicate at all never fires — an intentionally
cross-tenant admin query is out of scope; only attempted-but-optional scoping
is a finding. Where a caller genuinely wants the all-tenant query, that
intent belongs in an explicit method (or an inline sarj-noqa) rather than in
an omitted filter.
Measured before shipping: 0 findings across 26,345 files of pydantic, trio,
attrs, Airflow and Home Assistant — single-tenant codebases have no tenant
column, so the rule is silent by construction — and 0 in noura-be, ai, kpi-hub
and demo-gateway. In bulbul it finds 10 sites, all genuine fail-open
compositions, two of which were reachable cross-tenant reads at the time of
writing (POST /v1/calls/list and POST /v1/calls/batch/list, both of which
composed WHERE 1=1 for a user whose organization_id was NULL).
Assertions that can never fail (0.23.0)
- id: sarj-no-tautological-expect # SARJ057
SARJ057 fires when an assertion's operands are all literals, so its outcome is
fixed before the code runs. SARJ043 already catches the test with no
assertion; this is the test whose assertion is decorative.
The placeholder spelling (assert True) is the obvious half. The expensive half
is the assertion whose real condition slid out of the condition slot, because it
was a working assertion when it was typed:
assert { # ← braces, not parentheses
"referencing a non existing `via_device` " in caplog.text
} # one-element SET, always truthy
assert [f"No logs found on hdfs for ti={ti}"] # the `== messages` was lost
assert True, cover_result_json[0]["success"][...] # slid into the MESSAGE slot
The narrowness is the rule. The obvious generalisation — "flag a comparison
of a thing with itself" — measures ~95% false positives: assert i == i,
assert x is x, expect(hash([o])).toEqual(hash([o])) are reflexivity,
determinism and memoization tests, and for a type with custom __eq__/__hash__
they can genuinely fail. So an identifier, attribute or call operand is never
enough; both sides must be literals, and textually identical ones. assert True
as the sole statement of an except handler is exempt — it asserts which branch
ran — as is anything inside a pytest-benchmark test.
Measured before shipping: 4 findings across 28,608 files — 26,346 of
pydantic, trio, attrs, Airflow and Home Assistant plus 2,262 first-party files
in bulbul, noura-be, kpi-hub, ai and demo-gateway. All 4 are true positives
(Home Assistant tests/helpers/test_device_registry.py:3711 and :3777,
tests/components/emulated_hue/test_hue_api.py:1078, Airflow
providers/apache/hdfs/.../log/test_hdfs_task_handler.py:170); 0 false
positives. The two except ...: assert True markers that a carve-out-free
version does flag — pydantic-core/tests/benchmarks/test_micro_benchmarks.py:716
and core/tests/components/mqtt/test_client.py:1353 — were verified silent.
The TypeScript half of the same rule ships as @sarj/no-tautological-expect in
@sarj/eslint-plugin ≥ 2.14.0; until now there was no TS counterpart at all,
which is how expect(true).toBe(true); // placeholder survived in a suite named
for the behaviour it was supposed to check.
CLI
sarj-python-lint check --rule no-sequential-await path/to/file.py
sarj-python-lint list-rules
Diagnostic format is path:line:col: CODE message — Ruff-compatible.
Suppression
Inline # sarj-noqa: SARJ00X — <reason> on the offending line.
Each rule's source under src/sarj_python_lint/rules/ carries its own description and diagnostic message.
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 sarj_python_lint-0.25.0.tar.gz.
File metadata
- Download URL: sarj_python_lint-0.25.0.tar.gz
- Upload date:
- Size: 344.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6d481789227c6ba6831d4bdf5dd3c9fbe33a6b68f5f513abe12846c3e0c842c4
|
|
| MD5 |
86b6e8dd2c9409ebaeb924f192e47e85
|
|
| BLAKE2b-256 |
a6903e1b3643338756f6e62426cf906336838325da86942686439a6996158ea9
|
Provenance
The following attestation bundles were made for sarj_python_lint-0.25.0.tar.gz:
Publisher:
release.yml on sarj-ai/standards
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sarj_python_lint-0.25.0.tar.gz -
Subject digest:
6d481789227c6ba6831d4bdf5dd3c9fbe33a6b68f5f513abe12846c3e0c842c4 - Sigstore transparency entry: 2259353426
- Sigstore integration time:
-
Permalink:
sarj-ai/standards@2900c402d5edd6ec9481b5af2ef3233ec75a6283 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/sarj-ai
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@2900c402d5edd6ec9481b5af2ef3233ec75a6283 -
Trigger Event:
push
-
Statement type:
File details
Details for the file sarj_python_lint-0.25.0-py3-none-any.whl.
File metadata
- Download URL: sarj_python_lint-0.25.0-py3-none-any.whl
- Upload date:
- Size: 415.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1bebeb42823fffbb981df670d2b8211bd8fabef5cb130bf30371e032d033a1b2
|
|
| MD5 |
80710b61a168630b3ec8dddc229089a6
|
|
| BLAKE2b-256 |
4264e6be7c0702f20ae0827fed95bc420dfe4e105c9ec6b01821d439e18379e4
|
Provenance
The following attestation bundles were made for sarj_python_lint-0.25.0-py3-none-any.whl:
Publisher:
release.yml on sarj-ai/standards
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sarj_python_lint-0.25.0-py3-none-any.whl -
Subject digest:
1bebeb42823fffbb981df670d2b8211bd8fabef5cb130bf30371e032d033a1b2 - Sigstore transparency entry: 2259353619
- Sigstore integration time:
-
Permalink:
sarj-ai/standards@2900c402d5edd6ec9481b5af2ef3233ec75a6283 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/sarj-ai
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@2900c402d5edd6ec9481b5af2ef3233ec75a6283 -
Trigger Event:
push
-
Statement type: