Skip to main content

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 app.stores.order_store import _row_to_order (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 one first-party repo), so the same baseline ratchet applies.

Docstring-ceremony rules (0.31.0)

SARJ050 tests a function docstring against its own signature. That leaves three shapes it cannot reach, each now its own code so a consumer can baseline them separately:

    - id: sarj-duplicated-override-docstring       # SARJ084
    - id: sarj-redundant-class-docstring           # SARJ085
    - id: sarj-docstring-args-restate-signature    # SARJ086

SARJ084 flags an override whose docstring is byte-identical to the base method's, with the base resolved by undotted name inside the same file. There is no judgement call — the test is byte equality — and inspect.getdoc, help(), Sphinx and editor hovers all walk the MRO, so deleting the copy changes nothing a reader sees. 49 first-party findings, 49 true positives; 137 across 14 OSS repos, 18 sampled and read, 0 false positives.

SARJ085 flags a class docstring that only re-spells the class name — the case SARJ050's walker structurally never inspects. Its largest guard is that anything whose docstring becomes a published schema description (pydantic models, enums, TypedDicts, @strawberry.type) is exempt: that string is emitted as the JSON-Schema description and reaches OpenAPI documents and LLM tool schemas. The exemption costs 28 of 34 first-party findings and is not negotiable.

SARJ086 flags an Args: block whose every entry only re-spells its own parameter. It fires where SARJ050 cannot: the header word "args" is a content word no signature contains, so any Args: block makes a docstring permanently unflaggable by SARJ050 — 126 first-party functions carry one and SARJ050 flags none of them. The remedy deletes the section and keeps the summary, which was checked against the shipped strict config: ruff's D417 does not fire on a docstring with no parameter section.

Two more shapes were rejected on volume — property docstrings restating the property name and reST/epydoc type duplication (:type x: int, :rtype:) both measure 0 first-party findings outside generated code.

The Returns: half (0.36.0)

    - id: sarj-docstring-returns-restate-signature  # SARJ087

SARJ087 is the Returns: sibling of SARJ086, and it was rejected once: deleting a Returns: section used to make ruff's DOC201 fire, so the only compliant remedy was deleting the whole docstring. #164 then removed DOC201 from ruff.strict.toml as a rule that DEMANDS prose, and the premise expired — under the shipped config the section goes and the summary stays.

756 findings over 33 OSS repos / 35,254 files; two seeded samples of 40 and 20 read against source gave ~2% false positives, the whole of which was one family (Returns: A new X — whether the value is a copy is the one thing -> Self cannot say) now guarded. Three findings on this repo's own source, all true, all deleted. Measurements: docs/rules/SARJ087.md.

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 (three in the first repo — its package __init__.py, a dedicated configure_logging.py, and a service entrypoint main.py — plus a common/logging.py in the second) 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 one first-party repo, 14 in another, 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 seven 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-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 codenoqa:TID251 going 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 one first-party repo that are idiomatic where they sit, and "raw connection in a test" flagged 46 sites in another 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 across four other first-party repos. In the fifth it finds 10 sites, all genuine fail-open compositions, two of which were reachable cross-tenant reads at the time of writing (two paginated list endpoints, 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 across five first-party repos. 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

sarj_python_lint-0.36.1.tar.gz (215.6 kB view details)

Uploaded Source

Built Distribution

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

sarj_python_lint-0.36.1-py3-none-any.whl (295.5 kB view details)

Uploaded Python 3

File details

Details for the file sarj_python_lint-0.36.1.tar.gz.

File metadata

  • Download URL: sarj_python_lint-0.36.1.tar.gz
  • Upload date:
  • Size: 215.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for sarj_python_lint-0.36.1.tar.gz
Algorithm Hash digest
SHA256 8da619b1709636fe4aae56e29583fb49017dc400655abc30cb67e9af0b40c6f9
MD5 be80b878c7e60cb0c92df92bd45e23aa
BLAKE2b-256 19a93e3ff58986799da997b3ff8335170c8745d9b2522c9c73284466c733aa93

See more details on using hashes here.

Provenance

The following attestation bundles were made for sarj_python_lint-0.36.1.tar.gz:

Publisher: release.yml on sarj-ai/standards

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

File details

Details for the file sarj_python_lint-0.36.1-py3-none-any.whl.

File metadata

File hashes

Hashes for sarj_python_lint-0.36.1-py3-none-any.whl
Algorithm Hash digest
SHA256 843b81dd52bfde281446701e1724a86874dc857479ad8695e7d2aaae70538a6b
MD5 41ef00fcd2e68c80c0f05c9dff2ce867
BLAKE2b-256 0d8d56988a4a3bef3720eb54f3674698d850e16613b05cebccc96d301e52de85

See more details on using hashes here.

Provenance

The following attestation bundles were made for sarj_python_lint-0.36.1-py3-none-any.whl:

Publisher: release.yml on sarj-ai/standards

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

Release history Release notifications | RSS feed

0.77.1

2 files

0.77.0

2 files

0.76.0

2 files

0.75.0

2 files

0.74.0

2 files

0.73.2

2 files

0.73.1

2 files

0.73.0

2 files

0.72.0

2 files

0.71.0

2 files

0.70.1

2 files

0.70.0

2 files

0.69.1

2 files

0.69.0

2 files

0.68.0

2 files

0.67.14

2 files

0.67.13

2 files

0.67.12

2 files

0.67.11

2 files

0.67.10

2 files

0.67.9

2 files

0.67.8

2 files

0.67.7

2 files

0.67.6

2 files

0.67.5

2 files

0.67.4

2 files

0.67.3

2 files

0.67.2

2 files

0.67.0

1 file

0.66.0

1 file

0.65.0

1 file

0.64.0

1 file

0.63.0

1 file

0.62.0

1 file

0.61.0

1 file

0.60.0

1 file

0.59.0

1 file

0.58.0

1 file

0.57.0

1 file

0.56.0

1 file

0.55.0

1 file

0.54.1

1 file

0.54.0

1 file

0.53.0

1 file

0.52.1

1 file

0.52.0

1 file

0.51.3

1 file

0.51.1

1 file

0.51.0

1 file

0.50.0

1 file

0.49.0

1 file

0.48.0

1 file

0.47.1

1 file

0.47.0

1 file

0.46.0

2 files

0.45.0

2 files

0.44.0

2 files

0.43.2

2 files

0.43.0

2 files

0.42.0

2 files

0.41.0

2 files

0.40.0

2 files

0.39.0

2 files

0.38.0

2 files

0.37.0

2 files

This release

0.36.1 This release

2 files

0.36.0

2 files

0.35.0

2 files

0.34.0

2 files

0.33.0

2 files

0.32.0

2 files

0.31.0

2 files

0.30.0

2 files

0.29.0

2 files

0.28.0

2 files

0.27.0

2 files

0.26.0

2 files

0.25.0

2 files

0.24.0

2 files

0.23.0

2 files

0.21.0

2 files

0.20.0

2 files

0.19.0

2 files

0.18.1

2 files

0.18.0

2 files

0.17.0

2 files

0.16.0

2 files

0.15.1

2 files

0.15.0

2 files

0.14.0

2 files

0.13.1

2 files

0.12.2

2 files

0.12.1

2 files

0.12.0

2 files

0.11.1

2 files

0.11.0

2 files

0.10.1

2 files

0.10.0

2 files

0.9.0

2 files

0.8.0

2 files

0.7.0

2 files

0.6.0

2 files

0.5.0

2 files

0.4.1

2 files

0.4.0

2 files

0.3.0

2 files

0.2.0

2 files

Supported by

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