Metadata-Version: 2.4
Name: sarj-python-lint
Version: 0.49.0
Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
Project-URL: Homepage, https://github.com/sarj-ai/standards/tree/main/packages/python
Project-URL: Repository, https://github.com/sarj-ai/standards
Project-URL: Issues, https://github.com/sarj-ai/standards/issues
Author: sarj-ai
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Quality Assurance
Requires-Python: >=3.14
Description-Content-Type: text/markdown

# sarj-python-lint

Custom Python lint rules via stdlib `ast`. Designed for pre-commit. For SQL rules see [`sarj-sql-lint`](../sql/).

```bash
uv tool install sarj-python-lint
```

## Pre-commit

```yaml
- repo: https://github.com/sarj-ai/standards
  rev: python-v0.49.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-fastapi-openapi-contract            # SARJ094
    - id: sarj-no-hidden-constructor-fallback      # SARJ095 (warning)
    - 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
```

### FastAPI OpenAPI contracts (0.44.0)

`SARJ094` complements Ruff's `ANN*` and `FAST001`-`FAST003` checks. It requires
schema-visible operations to declare their summary, description and status;
uses described `Annotated` request markers; rejects schema-erasing response
shapes and response projections; requires explicit content schemas for direct
response objects; and keeps direct errors, custom responses, bodyless statuses,
GET/HEAD inputs and local route ordering honest in OpenAPI. Missing Python
annotations remain owned by Ruff's `ANN*` rules, so enable both policies.

The rule resolves module-level FastAPI imports and locally constructed or
aliased routers without guessing from names. Dynamic decorator mappings,
function-local framework imports, imported router instances and the assembled
`app.openapi()` document remain application-level integration-test concerns.
Existing projects can adopt the default-enabled rule with `--update-baseline`
and then shrink that baseline as endpoint contracts are repaired.

### Hidden constructor settings fallback (0.45.0)

`SARJ095` warns when a keyword-only constructor parameter defaults to `None`
and the constructor silently replaces it with a proven pydantic-settings value.
The effective dependency is invisible at the call site, and `value or
settings.VALUE` also treats an explicit falsey value as omitted. Make the
argument required and resolve the setting at the application composition root;
the annotation may remain nullable when `None` is still a valid explicit value.

The rule resolves same-module settings objects, imports, aliases and re-exports
back to an instance of a `pydantic_settings.BaseSettings` subclass. Literal and
enum defaults, mutable-container initialization, arbitrary factories and
clients, module constants, environment-variable APIs, other parameters and
instance state are deliberately outside v1. No autofix is offered because
changing constructor optionality requires coordinated call-site edits.

Measured over 4,638 tracked Python files in 33 first-party repositories: three
constructor warnings across two repositories, all three actionable. A pinned
15-repository OSS sweep covered 29,203 files and produced zero reports, which is
compatibility evidence rather than a precision claim. An environment-variable
arm was rejected before shipping: it reported a public first-party library and
two intentional LiteLLM integration constructors, all non-actionable.

### 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; `sarj-python-lint explain` links each rule to its behavioral tests.

```yaml
    - 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)

```yaml
    - 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, with each false-positive guard encoded in
the behavioral tests linked by `sarj-python-lint explain`.

```yaml
    - 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:

```yaml
    - 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, `TypedDict`s, `@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)

```yaml
    - 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. The guarded copy-return case is covered by the paired rule tests.

### Test ceremony, and the census it was chosen from (0.38.0)

```yaml
    - id: sarj-restated-test-docstring              # SARJ088
    - id: sarj-test-phase-label-comment             # SARJ089
```

Every comment GROUP in 19 repositories / 45,900 Python files was collected with
its adjacent code and classified: **451,482 groups, 1,293,022 lines**, of which
the seven comment/docstring rules that predate this release reached **4.9%**.
The shipped predicate and its boundaries are recorded in the paired rule tests.

The largest precisely-detectable class left in it is the **test docstring**:
52,894 of them, 10.1% of every comment group, and SARJ050 reached 4.7%. It
reached so few because SARJ050 measures a docstring against its *signature*, and
a test's specification is its BODY. `SARJ088` measures it against the signature,
the identifiers in the test's own body, and the vocabulary a test docstring
spends on being a test. **5,382 findings; 98 read at source, 0 false positives
on the shipped predicate.**

`SARJ089` deletes the bare `# given` / `# when` / `# then` / `# Arrange` /
`# Act` / `# Assert` phase label. 27,714 findings, 36 read, 0 false positives —
but **94.5% come from one OSS suite**, and none from any first-party repo. It is
a fence against the convention arriving, not a cleanup; adopt it behind the
baseline ratchet.

Shipped with them, `_docstrings.STOPWORDS` gained `FILLER_QUALIFIERS`: 31
qualifiers that narrow nothing (`specific`, `appropriate`, `entire`, `overall`).
One of these was the commonest single reason a pure restatement survived the
whole family — `"""Get a specific account by ID."""` over
`get_account(self, account_id: str)`. **+683 findings across SARJ050/085/086/087,
-0; 58 of the delta read, ~3.4% false positives.** `main` was tried and rejected:
as filler it makes `"""Main function."""` content-free, hence unflaggable.

**Three shapes measured on the same census and rejected**, each on a seeded
12-finding read at source:

| shape | findings | true | why it fails |
| --- | ---: | ---: | --- |
| comment restates the `if` / `for` / `with` header below it | 225 | 3/12 | the population is BRANCH LABELS naming a case (`# PIL.Image` over `if isinstance(item, PILImage.Image)`), not narration |
| comment restates a plain assignment (no call on the RHS) | 308 | 2/12 | the population heads a multi-line literal or a 3-statement block — a section label, which is SARJ016's subject |
| multi-line comment run restating the block it heads | 119 | 1-2/12 | banners, Sphinx `#:` attribute docs, and worked calculations dominate |

Those three are why SARJ049 still excludes block openers, plain assignments and
multi-line runs. The exclusions are load-bearing, not unfinished work.

### House conventions moved out of consumer repos (0.21.0)

```yaml
    - 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.

```yaml
    - 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)

```yaml
    - id: sarj-prefer-fstring-over-concat          # SARJ068
    - id: sarj-prefer-or-pattern                   # SARJ070
    - id: sarj-require-port-for-service            # SARJ071
```

`require-port-for-service` treats a local concrete superclass differently from
an abstract/Protocol port and follows local port inheritance transitively.
Class-size enforcement stays with Ruff `PLR0904` (enabled by the strict `ALL`
selection); a second custom size rule would duplicate that owner.

Tuple boundaries are deliberately stricter than Ruff: SARJ026 rejects fixed
multi-field tuple annotations on public production functions, including local
aliases and abstract/NotImplemented contracts, while SARJ044 applies the same
named-result requirement to pytest fixtures. Variadic `tuple[T, ...]` remains a
sequence rather than a positional record. Prefer `typing.NamedTuple`, a frozen
dataclass, or a frozen validation model at schema boundaries.

SARJ093 recognizes `str`, `int`, UUID and supported containers as non-nominal ID
carriers, while preserving `NewType` aliases as nominal identities. SARJ006
keeps wire-derived values open, respects `Literal`/enum annotations across
closures and comprehensions, and recognizes explicit and Django-style choice
collections.
### Suppression ratchet (`sarj-ratchet`, 0.21.0)

```yaml
    - 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: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`.

```bash
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:

```bash
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)

```yaml
    - 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:

```python
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:

```python
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)

```yaml
    - 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:

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

```bash
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.
