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 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 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 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

sarj_python_lint-0.30.0.tar.gz (364.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.30.0-py3-none-any.whl (441.1 kB view details)

Uploaded Python 3

File details

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

File metadata

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

File hashes

Hashes for sarj_python_lint-0.30.0.tar.gz
Algorithm Hash digest
SHA256 490b7101d61f320b1f532a8d887b8ec27826ffd8bfde2bdb45ca7a6a1b91a1a2
MD5 5fff58640530965b2a376e8568caae10
BLAKE2b-256 18122455ad84cde81141179c4ec0fbb8b6b778b15c3eb47ed45c1e321b4e7042

See more details on using hashes here.

Provenance

The following attestation bundles were made for sarj_python_lint-0.30.0.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.30.0-py3-none-any.whl.

File metadata

File hashes

Hashes for sarj_python_lint-0.30.0-py3-none-any.whl
Algorithm Hash digest
SHA256 1da85eac609d73b0aadcb0c1639e90b6151744acf73392d20894a4a85d2b6ad1
MD5 7b613d2f7eaf4215e40f0df7fe74fe73
BLAKE2b-256 d0adaab919b9be943ab4c29be58136a8b1de7a1855c6c4925f53c8ed893dfe32

See more details on using hashes here.

Provenance

The following attestation bundles were made for sarj_python_lint-0.30.0-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

0.36.1

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

This release

0.30.0 This release

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