sarj-python-lint 0.20.0__tar.gz → 0.23.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. sarj_python_lint-0.23.0/PKG-INFO +298 -0
  2. sarj_python_lint-0.23.0/README.md +280 -0
  3. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/pyproject.toml +2 -1
  4. sarj_python_lint-0.23.0/src/sarj_python_lint/_ratchet_cli.py +185 -0
  5. sarj_python_lint-0.23.0/src/sarj_python_lint/ratchet.py +423 -0
  6. sarj_python_lint-0.23.0/src/sarj_python_lint/rules/_pytest.py +61 -0
  7. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/_registry.py +25 -0
  8. sarj_python_lint-0.23.0/src/sarj_python_lint/rules/_suppression_comments.py +78 -0
  9. sarj_python_lint-0.23.0/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +150 -0
  10. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +3 -64
  11. sarj_python_lint-0.23.0/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +118 -0
  12. sarj_python_lint-0.23.0/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +192 -0
  13. sarj_python_lint-0.23.0/src/sarj_python_lint/rules/no_stdlib_logging.py +195 -0
  14. sarj_python_lint-0.23.0/src/sarj_python_lint/rules/no_tautological_expect.py +374 -0
  15. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/zero_assertion_test.py +2 -13
  16. sarj_python_lint-0.20.0/PKG-INFO +0 -125
  17. sarj_python_lint-0.20.0/README.md +0 -107
  18. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/.gitignore +0 -0
  19. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/__init__.py +0 -0
  20. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/__main__.py +0 -0
  21. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/_secret_names.py +0 -0
  22. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/_version.py +0 -0
  23. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/py.typed +0 -0
  24. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rule_base.py +0 -0
  25. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  26. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  27. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  28. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  29. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  30. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  31. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  32. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  33. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  34. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
  35. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  36. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  37. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  38. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  39. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  40. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  41. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  42. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  43. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  44. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  45. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  46. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  47. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  48. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  49. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  50. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  51. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  52. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  53. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  54. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
  55. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  56. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  57. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  58. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  59. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  60. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  61. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  62. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  63. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  64. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  65. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  66. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  67. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  68. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  69. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
  70. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  71. {sarj_python_lint-0.20.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
@@ -0,0 +1,298 @@
1
+ Metadata-Version: 2.4
2
+ Name: sarj-python-lint
3
+ Version: 0.23.0
4
+ Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
+ Project-URL: Homepage, https://github.com/sarj-ai/standards/tree/main/packages/python
6
+ Project-URL: Repository, https://github.com/sarj-ai/standards
7
+ Project-URL: Issues, https://github.com/sarj-ai/standards/issues
8
+ Author: sarj-ai
9
+ License: MIT
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: Software Development :: Quality Assurance
16
+ Requires-Python: >=3.14
17
+ Description-Content-Type: text/markdown
18
+
19
+ # sarj-python-lint
20
+
21
+ Custom Python lint rules via stdlib `ast`. Designed for pre-commit. For SQL rules see [`sarj-sql-lint`](../sql/).
22
+
23
+ ```bash
24
+ uv tool install sarj-python-lint
25
+ ```
26
+
27
+ ## Pre-commit
28
+
29
+ ```yaml
30
+ - repo: https://github.com/sarj-ai/standards
31
+ rev: python-v0.2.0
32
+ hooks:
33
+ - id: sarj-no-sequential-await
34
+ - id: sarj-inefficient-string-concat-in-loop
35
+ - id: sarj-prefer-str-enum
36
+ - id: sarj-no-fat-try-blocks
37
+ - id: sarj-pydantic-at-boundaries
38
+ - id: sarj-prefer-class-row
39
+ - id: sarj-prefer-timedelta-for-durations
40
+ - id: sarj-prefer-struct-over-namedtuple
41
+ - id: sarj-no-comment-cruft
42
+ - id: sarj-no-fstring-in-log
43
+ ```
44
+
45
+ ### Test-quality rules (0.15.0)
46
+
47
+ Mined from an AST audit of ~7,500 test functions across two production repos.
48
+ Every one is scoped to test files and carries the false-positive guard that made
49
+ it shippable; the module docstring for each records the population it was
50
+ measured against.
51
+
52
+ ```yaml
53
+ - id: sarj-mock-without-spec # SARJ040
54
+ - id: sarj-test-loops-over-literal-cases # SARJ041
55
+ - id: sarj-parametrize-case-needs-id # SARJ042
56
+ - id: sarj-zero-assertion-test # SARJ043
57
+ - id: sarj-fixture-returns-bare-tuple # SARJ044
58
+ - id: sarj-kwarg-heavy-construction-in-test # SARJ045
59
+ - id: sarj-xfail-requires-strict # SARJ046
60
+ - id: sarj-sleep-with-computed-arg-in-test # SARJ047
61
+ ```
62
+
63
+ ### Private access, first-party only (0.19.0)
64
+
65
+ ```yaml
66
+ - id: sarj-no-first-party-private-import # SARJ048
67
+ ```
68
+
69
+ Reaching past a module's public surface is a design finding when the module is
70
+ ours and an unavoidable fact of life when it is not: a dependency that moves an
71
+ API private in a minor release leaves no edit that satisfies the lint.
72
+
73
+ `SARJ048` fires only when the module declaring the private name resolves to a
74
+ package inside your own project. Third-party privates are never flagged.
75
+
76
+ **It replaces ruff's `PLC2701 import-private-name`,** whose only exemption is
77
+ *same top-level package* — a different question, and one that cannot separate
78
+ `from bulbul.stores.task_store import _row_to_task` (real; export it) from
79
+ `from livekit.agents.inference_runner import _InferenceRunner` (no fix exists).
80
+ `sarj-lint-configs` ≥ 0.8.0 ships `PLC2701` in its ignore list for exactly this
81
+ reason; if you take that config, turn this hook on, or you lose the check
82
+ entirely.
83
+
84
+ Attribute access (`session._stt`) is out of scope and stays with ruff's
85
+ `SLF001`, which cannot make the distinction either — see the rationale in
86
+ `ruff.strict.toml`.
87
+
88
+ ### Comment-hygiene rules (0.20.0)
89
+
90
+ From a 37,918-comment, nine-repo measurement study. All three are
91
+ deletion-class, so each was validated against pydantic / trio / attrs as well as
92
+ the maintained repos before shipping — the counts and the false-positive classes
93
+ each guard was built from are recorded in the rule module docstrings.
94
+
95
+ ```yaml
96
+ - id: sarj-no-restated-comment # SARJ049
97
+ - id: sarj-redundant-docstring # SARJ050
98
+ - id: sarj-trailing-value-narration # SARJ051
99
+ ```
100
+
101
+ `redundant-docstring` finds real volume on a codebase that has never had it
102
+ (105 in noura-be), so the same baseline ratchet applies.
103
+
104
+ ### House conventions moved out of consumer repos (0.21.0)
105
+
106
+ ```yaml
107
+ - id: sarj-no-stdlib-logging # SARJ052
108
+ - id: sarj-no-gen-random-uuid-in-sql # SARJ053
109
+ - id: sarj-no-file-level-escape-hatch-noqa # SARJ054
110
+ ```
111
+
112
+ `SARJ052` bans importing stdlib `logging` in application code, because the
113
+ house logger is loguru and two logger hierarchies mean two handler chains: the
114
+ records written to the one nobody configured skip the JSON formatter, the
115
+ redaction patcher and the error reporter, and — since the stdlib root defaults
116
+ to WARNING — usually vanish in production while looking fine locally.
117
+
118
+ The one legitimate reason to touch stdlib logging in a loguru house is to
119
+ *bridge* it, and the bridge cannot be written without naming both loggers, so a
120
+ module importing loguru is exempt. Measured across two production repos that
121
+ exemption is exact: all four sites that import stdlib logging
122
+ (`bulbul/__init__.py`, `bulbul/configure_logging.py`, `agent/main.py`,
123
+ noura-be's `common/logging.py`) are bridges, all four import loguru, and no
124
+ other module in either repo imports stdlib logging at all. Tests, `scripts/`,
125
+ `notebooks/`, generated files and `if TYPE_CHECKING:` imports are also exempt.
126
+
127
+ **This is a house-convention rule, not a universal one.** A *library* should log
128
+ through stdlib `logging` precisely so it does not impose a sink on its callers —
129
+ trio's three sites are correct for trio. Enable it in applications only.
130
+
131
+ `SARJ053` flags `gen_random_uuid()` in SQL embedded in a Python string literal:
132
+ UUIDv4 keys scatter B-tree inserts across every leaf page, where `uuidv7()`
133
+ (Postgres 18) is time-ordered and appends. It is the embedded-SQL third of a
134
+ policy the stack already states twice — `ruff.strict.toml` bans `uuid.uuid4`,
135
+ and `sarj-sql-lint`'s SARJ109 `prefer-uuidv7-default` covers `.sql` migration
136
+ files (41 sites in bulbul, 14 in noura-be, all of them a primary-key `DEFAULT`).
137
+ A literal only counts when it is SQL-shaped, so prose naming the function is not
138
+ a finding.
139
+
140
+ `SARJ054` is SARJ038's scoped sibling. SARJ038 bans the unscoped blanket
141
+ (`# ruff: noqa`); this bans a *scoped* file-level exemption that names an
142
+ escape-hatch code — a code whose remediation `ruff.strict.toml` spells as an
143
+ inline `# noqa: CODE — <reason>`, which today is `TID251` alone, ruff's only
144
+ banned-API code. Hoisting that to the top of a file turns N reviewed per-site
145
+ decisions into one unreviewable one and pre-authorizes every mock added later.
146
+ Scoped exemptions for mechanical codes (`E501`, `F401`, `UP035`) are never
147
+ flagged — measured across five repos those are the entire population.
148
+
149
+ ### Suppression ratchet (`sarj-ratchet`, 0.21.0)
150
+
151
+ ```yaml
152
+ - id: sarj-suppression-ratchet
153
+ ```
154
+
155
+ One tool replacing the per-repo ratchet scripts. It counts every escape hatch in
156
+ the tree and enforces three ceilings that may only shrink:
157
+
158
+ * **per code** — `noqa:TID251` going 40 → 41 is a regression even if the total falls
159
+ * **per package** — one package's headroom must not finance another's debt
160
+ * **per file** — a global cap so new suppressions cannot pile into one hot spot;
161
+ pre-existing hot spots are grandfathered at their then-current counts
162
+
163
+ All four dialects are counted under distinct key prefixes, so moving a
164
+ suppression between spellings can never hide it: `noqa:CODE`,
165
+ `sarj-noqa:CODE`, `pyright:CODE`, `type-ignore:CODE` / bare `type-ignore`, plus
166
+ the file-level `file-noqa:CODE` / `file-noqa:<blanket>` and `file-pyright:RULE`.
167
+
168
+ ```bash
169
+ sarj-ratchet --update python/ # seed (or lock in a drop)
170
+ sarj-ratchet python/ # gate
171
+ sarj-ratchet --update --allow-increase python/ # a reviewed ceiling raise
172
+ ```
173
+
174
+ `--update` **refuses** to raise a ceiling unless `--allow-increase` says the
175
+ raise was reviewed, and it drops a per-file grandfather clause as soon as the
176
+ file falls back under the global cap, so an allowance cannot outlive its debt.
177
+
178
+ ### Two conventions that stayed pygrep
179
+
180
+ `sarj-fakes-in-shared-location` and `sarj-no-raw-connection-in-tests` ship as
181
+ pygrep hooks, not SARJ rules, and both need a `files:`/`exclude:` from the
182
+ consumer. An AST port of each was built and measured, and the boundary each
183
+ encodes turned out to be repo-specific rather than shared: "shared fake" flagged
184
+ 9/9 single-use test doubles in noura-be that are idiomatic where they sit, and
185
+ "raw connection in a test" flagged 46 sites in bulbul of which every one is
186
+ already an intentional exemption (store tests asserting DB state, pool-lifecycle
187
+ tests, retention tests where physical deletion is the subject). SARJ036
188
+ `no-raw-sql-in-tests` remains the corpus-validated shared rule for raw SQL in
189
+ tests.
190
+
191
+ Adopting these against an existing suite is easier through the baseline ratchet
192
+ than as a big-bang fix — snapshot the current counts, then let them only shrink:
193
+
194
+ ```bash
195
+ sarj-python-lint check --rule mock-without-spec --update-baseline test-quality-baseline.json python/
196
+ sarj-python-lint check --rule mock-without-spec --baseline test-quality-baseline.json python/
197
+ ```
198
+
199
+ ### Multi-tenant scoping (0.22.0)
200
+
201
+ ```yaml
202
+ - id: sarj-no-optional-tenant-predicate # SARJ056
203
+ ```
204
+
205
+ `SARJ056` fires when every WHERE-fragment mentioning a tenant column
206
+ (`organization_id` and friends) sits inside a conditional, so the predicate
207
+ disappears — and the query still runs — whenever the filter is empty:
208
+
209
+ ```python
210
+ where_conditions = []
211
+ if args.organization_ids: # ← optional
212
+ where_conditions.append(SQL("organization_id = ANY(%s::uuid[])"))
213
+ ...
214
+ where_clause = SQL(" AND ").join(where_conditions) if where_conditions else SQL("1=1")
215
+ ```
216
+
217
+ The safe idiom seeds the list, so scoping always applies and the rule stays
218
+ quiet:
219
+
220
+ ```python
221
+ conditions: list[Composable] = [SQL("organization_id = %s")]
222
+ ```
223
+
224
+ A function with **no** tenant predicate at all never fires — an intentionally
225
+ cross-tenant admin query is out of scope; only *attempted-but-optional* scoping
226
+ is a finding. Where a caller genuinely wants the all-tenant query, that
227
+ intent belongs in an explicit method (or an inline `sarj-noqa`) rather than in
228
+ an omitted filter.
229
+
230
+ Measured before shipping: **0 findings across 26,345 files** of pydantic, trio,
231
+ attrs, Airflow and Home Assistant — single-tenant codebases have no tenant
232
+ column, so the rule is silent by construction — and 0 in noura-be, ai, kpi-hub
233
+ and demo-gateway. In bulbul it finds 10 sites, all genuine fail-open
234
+ compositions, two of which were reachable cross-tenant reads at the time of
235
+ writing (`POST /v1/calls/list` and `POST /v1/calls/batch/list`, both of which
236
+ composed `WHERE 1=1` for a user whose `organization_id` was NULL).
237
+
238
+ ### Assertions that can never fail (0.23.0)
239
+
240
+ ```yaml
241
+ - id: sarj-no-tautological-expect # SARJ057
242
+ ```
243
+
244
+ `SARJ057` fires when an assertion's operands are all literals, so its outcome is
245
+ fixed before the code runs. `SARJ043` already catches the test with *no*
246
+ assertion; this is the test whose assertion is decorative.
247
+
248
+ The placeholder spelling (`assert True`) is the obvious half. The expensive half
249
+ is the assertion whose real condition slid out of the condition slot, because it
250
+ was a working assertion when it was typed:
251
+
252
+ ```python
253
+ assert { # ← braces, not parentheses
254
+ "referencing a non existing `via_device` " in caplog.text
255
+ } # one-element SET, always truthy
256
+
257
+ assert [f"No logs found on hdfs for ti={ti}"] # the `== messages` was lost
258
+ assert True, cover_result_json[0]["success"][...] # slid into the MESSAGE slot
259
+ ```
260
+
261
+ **The narrowness is the rule.** The obvious generalisation — "flag a comparison
262
+ of a thing with itself" — measures ~95% false positives: `assert i == i`,
263
+ `assert x is x`, `expect(hash([o])).toEqual(hash([o]))` are reflexivity,
264
+ determinism and memoization tests, and for a type with custom `__eq__`/`__hash__`
265
+ they can genuinely fail. So an identifier, attribute or call operand is never
266
+ enough; both sides must be literals, and textually identical ones. `assert True`
267
+ as the sole statement of an `except` handler is exempt — it asserts *which branch
268
+ ran* — as is anything inside a pytest-benchmark test.
269
+
270
+ Measured before shipping: **4 findings across 28,608 files** — 26,346 of
271
+ pydantic, trio, attrs, Airflow and Home Assistant plus 2,262 first-party files
272
+ in bulbul, noura-be, kpi-hub, ai and demo-gateway. All 4 are true positives
273
+ (Home Assistant `tests/helpers/test_device_registry.py:3711` and `:3777`,
274
+ `tests/components/emulated_hue/test_hue_api.py:1078`, Airflow
275
+ `providers/apache/hdfs/.../log/test_hdfs_task_handler.py:170`); 0 false
276
+ positives. The two `except ...: assert True` markers that a carve-out-free
277
+ version does flag — `pydantic-core/tests/benchmarks/test_micro_benchmarks.py:716`
278
+ and `core/tests/components/mqtt/test_client.py:1353` — were verified silent.
279
+
280
+ The TypeScript half of the same rule ships as `@sarj/no-tautological-expect` in
281
+ `@sarj/eslint-plugin` ≥ 2.14.0; until now there was no TS counterpart at all,
282
+ which is how `expect(true).toBe(true); // placeholder` survived in a suite named
283
+ for the behaviour it was supposed to check.
284
+
285
+ ## CLI
286
+
287
+ ```bash
288
+ sarj-python-lint check --rule no-sequential-await path/to/file.py
289
+ sarj-python-lint list-rules
290
+ ```
291
+
292
+ Diagnostic format is `path:line:col: CODE message` — Ruff-compatible.
293
+
294
+ ## Suppression
295
+
296
+ Inline `# sarj-noqa: SARJ00X — <reason>` on the offending line.
297
+
298
+ Each rule's source under `src/sarj_python_lint/rules/` carries its own `description` and diagnostic message.
@@ -0,0 +1,280 @@
1
+ # sarj-python-lint
2
+
3
+ Custom Python lint rules via stdlib `ast`. Designed for pre-commit. For SQL rules see [`sarj-sql-lint`](../sql/).
4
+
5
+ ```bash
6
+ uv tool install sarj-python-lint
7
+ ```
8
+
9
+ ## Pre-commit
10
+
11
+ ```yaml
12
+ - repo: https://github.com/sarj-ai/standards
13
+ rev: python-v0.2.0
14
+ hooks:
15
+ - id: sarj-no-sequential-await
16
+ - id: sarj-inefficient-string-concat-in-loop
17
+ - id: sarj-prefer-str-enum
18
+ - id: sarj-no-fat-try-blocks
19
+ - id: sarj-pydantic-at-boundaries
20
+ - id: sarj-prefer-class-row
21
+ - id: sarj-prefer-timedelta-for-durations
22
+ - id: sarj-prefer-struct-over-namedtuple
23
+ - id: sarj-no-comment-cruft
24
+ - id: sarj-no-fstring-in-log
25
+ ```
26
+
27
+ ### Test-quality rules (0.15.0)
28
+
29
+ Mined from an AST audit of ~7,500 test functions across two production repos.
30
+ Every one is scoped to test files and carries the false-positive guard that made
31
+ it shippable; the module docstring for each records the population it was
32
+ measured against.
33
+
34
+ ```yaml
35
+ - id: sarj-mock-without-spec # SARJ040
36
+ - id: sarj-test-loops-over-literal-cases # SARJ041
37
+ - id: sarj-parametrize-case-needs-id # SARJ042
38
+ - id: sarj-zero-assertion-test # SARJ043
39
+ - id: sarj-fixture-returns-bare-tuple # SARJ044
40
+ - id: sarj-kwarg-heavy-construction-in-test # SARJ045
41
+ - id: sarj-xfail-requires-strict # SARJ046
42
+ - id: sarj-sleep-with-computed-arg-in-test # SARJ047
43
+ ```
44
+
45
+ ### Private access, first-party only (0.19.0)
46
+
47
+ ```yaml
48
+ - id: sarj-no-first-party-private-import # SARJ048
49
+ ```
50
+
51
+ Reaching past a module's public surface is a design finding when the module is
52
+ ours and an unavoidable fact of life when it is not: a dependency that moves an
53
+ API private in a minor release leaves no edit that satisfies the lint.
54
+
55
+ `SARJ048` fires only when the module declaring the private name resolves to a
56
+ package inside your own project. Third-party privates are never flagged.
57
+
58
+ **It replaces ruff's `PLC2701 import-private-name`,** whose only exemption is
59
+ *same top-level package* — a different question, and one that cannot separate
60
+ `from bulbul.stores.task_store import _row_to_task` (real; export it) from
61
+ `from livekit.agents.inference_runner import _InferenceRunner` (no fix exists).
62
+ `sarj-lint-configs` ≥ 0.8.0 ships `PLC2701` in its ignore list for exactly this
63
+ reason; if you take that config, turn this hook on, or you lose the check
64
+ entirely.
65
+
66
+ Attribute access (`session._stt`) is out of scope and stays with ruff's
67
+ `SLF001`, which cannot make the distinction either — see the rationale in
68
+ `ruff.strict.toml`.
69
+
70
+ ### Comment-hygiene rules (0.20.0)
71
+
72
+ From a 37,918-comment, nine-repo measurement study. All three are
73
+ deletion-class, so each was validated against pydantic / trio / attrs as well as
74
+ the maintained repos before shipping — the counts and the false-positive classes
75
+ each guard was built from are recorded in the rule module docstrings.
76
+
77
+ ```yaml
78
+ - id: sarj-no-restated-comment # SARJ049
79
+ - id: sarj-redundant-docstring # SARJ050
80
+ - id: sarj-trailing-value-narration # SARJ051
81
+ ```
82
+
83
+ `redundant-docstring` finds real volume on a codebase that has never had it
84
+ (105 in noura-be), so the same baseline ratchet applies.
85
+
86
+ ### House conventions moved out of consumer repos (0.21.0)
87
+
88
+ ```yaml
89
+ - id: sarj-no-stdlib-logging # SARJ052
90
+ - id: sarj-no-gen-random-uuid-in-sql # SARJ053
91
+ - id: sarj-no-file-level-escape-hatch-noqa # SARJ054
92
+ ```
93
+
94
+ `SARJ052` bans importing stdlib `logging` in application code, because the
95
+ house logger is loguru and two logger hierarchies mean two handler chains: the
96
+ records written to the one nobody configured skip the JSON formatter, the
97
+ redaction patcher and the error reporter, and — since the stdlib root defaults
98
+ to WARNING — usually vanish in production while looking fine locally.
99
+
100
+ The one legitimate reason to touch stdlib logging in a loguru house is to
101
+ *bridge* it, and the bridge cannot be written without naming both loggers, so a
102
+ module importing loguru is exempt. Measured across two production repos that
103
+ exemption is exact: all four sites that import stdlib logging
104
+ (`bulbul/__init__.py`, `bulbul/configure_logging.py`, `agent/main.py`,
105
+ noura-be's `common/logging.py`) are bridges, all four import loguru, and no
106
+ other module in either repo imports stdlib logging at all. Tests, `scripts/`,
107
+ `notebooks/`, generated files and `if TYPE_CHECKING:` imports are also exempt.
108
+
109
+ **This is a house-convention rule, not a universal one.** A *library* should log
110
+ through stdlib `logging` precisely so it does not impose a sink on its callers —
111
+ trio's three sites are correct for trio. Enable it in applications only.
112
+
113
+ `SARJ053` flags `gen_random_uuid()` in SQL embedded in a Python string literal:
114
+ UUIDv4 keys scatter B-tree inserts across every leaf page, where `uuidv7()`
115
+ (Postgres 18) is time-ordered and appends. It is the embedded-SQL third of a
116
+ policy the stack already states twice — `ruff.strict.toml` bans `uuid.uuid4`,
117
+ and `sarj-sql-lint`'s SARJ109 `prefer-uuidv7-default` covers `.sql` migration
118
+ files (41 sites in bulbul, 14 in noura-be, all of them a primary-key `DEFAULT`).
119
+ A literal only counts when it is SQL-shaped, so prose naming the function is not
120
+ a finding.
121
+
122
+ `SARJ054` is SARJ038's scoped sibling. SARJ038 bans the unscoped blanket
123
+ (`# ruff: noqa`); this bans a *scoped* file-level exemption that names an
124
+ escape-hatch code — a code whose remediation `ruff.strict.toml` spells as an
125
+ inline `# noqa: CODE — <reason>`, which today is `TID251` alone, ruff's only
126
+ banned-API code. Hoisting that to the top of a file turns N reviewed per-site
127
+ decisions into one unreviewable one and pre-authorizes every mock added later.
128
+ Scoped exemptions for mechanical codes (`E501`, `F401`, `UP035`) are never
129
+ flagged — measured across five repos those are the entire population.
130
+
131
+ ### Suppression ratchet (`sarj-ratchet`, 0.21.0)
132
+
133
+ ```yaml
134
+ - id: sarj-suppression-ratchet
135
+ ```
136
+
137
+ One tool replacing the per-repo ratchet scripts. It counts every escape hatch in
138
+ the tree and enforces three ceilings that may only shrink:
139
+
140
+ * **per code** — `noqa:TID251` going 40 → 41 is a regression even if the total falls
141
+ * **per package** — one package's headroom must not finance another's debt
142
+ * **per file** — a global cap so new suppressions cannot pile into one hot spot;
143
+ pre-existing hot spots are grandfathered at their then-current counts
144
+
145
+ All four dialects are counted under distinct key prefixes, so moving a
146
+ suppression between spellings can never hide it: `noqa:CODE`,
147
+ `sarj-noqa:CODE`, `pyright:CODE`, `type-ignore:CODE` / bare `type-ignore`, plus
148
+ the file-level `file-noqa:CODE` / `file-noqa:<blanket>` and `file-pyright:RULE`.
149
+
150
+ ```bash
151
+ sarj-ratchet --update python/ # seed (or lock in a drop)
152
+ sarj-ratchet python/ # gate
153
+ sarj-ratchet --update --allow-increase python/ # a reviewed ceiling raise
154
+ ```
155
+
156
+ `--update` **refuses** to raise a ceiling unless `--allow-increase` says the
157
+ raise was reviewed, and it drops a per-file grandfather clause as soon as the
158
+ file falls back under the global cap, so an allowance cannot outlive its debt.
159
+
160
+ ### Two conventions that stayed pygrep
161
+
162
+ `sarj-fakes-in-shared-location` and `sarj-no-raw-connection-in-tests` ship as
163
+ pygrep hooks, not SARJ rules, and both need a `files:`/`exclude:` from the
164
+ consumer. An AST port of each was built and measured, and the boundary each
165
+ encodes turned out to be repo-specific rather than shared: "shared fake" flagged
166
+ 9/9 single-use test doubles in noura-be that are idiomatic where they sit, and
167
+ "raw connection in a test" flagged 46 sites in bulbul of which every one is
168
+ already an intentional exemption (store tests asserting DB state, pool-lifecycle
169
+ tests, retention tests where physical deletion is the subject). SARJ036
170
+ `no-raw-sql-in-tests` remains the corpus-validated shared rule for raw SQL in
171
+ tests.
172
+
173
+ Adopting these against an existing suite is easier through the baseline ratchet
174
+ than as a big-bang fix — snapshot the current counts, then let them only shrink:
175
+
176
+ ```bash
177
+ sarj-python-lint check --rule mock-without-spec --update-baseline test-quality-baseline.json python/
178
+ sarj-python-lint check --rule mock-without-spec --baseline test-quality-baseline.json python/
179
+ ```
180
+
181
+ ### Multi-tenant scoping (0.22.0)
182
+
183
+ ```yaml
184
+ - id: sarj-no-optional-tenant-predicate # SARJ056
185
+ ```
186
+
187
+ `SARJ056` fires when every WHERE-fragment mentioning a tenant column
188
+ (`organization_id` and friends) sits inside a conditional, so the predicate
189
+ disappears — and the query still runs — whenever the filter is empty:
190
+
191
+ ```python
192
+ where_conditions = []
193
+ if args.organization_ids: # ← optional
194
+ where_conditions.append(SQL("organization_id = ANY(%s::uuid[])"))
195
+ ...
196
+ where_clause = SQL(" AND ").join(where_conditions) if where_conditions else SQL("1=1")
197
+ ```
198
+
199
+ The safe idiom seeds the list, so scoping always applies and the rule stays
200
+ quiet:
201
+
202
+ ```python
203
+ conditions: list[Composable] = [SQL("organization_id = %s")]
204
+ ```
205
+
206
+ A function with **no** tenant predicate at all never fires — an intentionally
207
+ cross-tenant admin query is out of scope; only *attempted-but-optional* scoping
208
+ is a finding. Where a caller genuinely wants the all-tenant query, that
209
+ intent belongs in an explicit method (or an inline `sarj-noqa`) rather than in
210
+ an omitted filter.
211
+
212
+ Measured before shipping: **0 findings across 26,345 files** of pydantic, trio,
213
+ attrs, Airflow and Home Assistant — single-tenant codebases have no tenant
214
+ column, so the rule is silent by construction — and 0 in noura-be, ai, kpi-hub
215
+ and demo-gateway. In bulbul it finds 10 sites, all genuine fail-open
216
+ compositions, two of which were reachable cross-tenant reads at the time of
217
+ writing (`POST /v1/calls/list` and `POST /v1/calls/batch/list`, both of which
218
+ composed `WHERE 1=1` for a user whose `organization_id` was NULL).
219
+
220
+ ### Assertions that can never fail (0.23.0)
221
+
222
+ ```yaml
223
+ - id: sarj-no-tautological-expect # SARJ057
224
+ ```
225
+
226
+ `SARJ057` fires when an assertion's operands are all literals, so its outcome is
227
+ fixed before the code runs. `SARJ043` already catches the test with *no*
228
+ assertion; this is the test whose assertion is decorative.
229
+
230
+ The placeholder spelling (`assert True`) is the obvious half. The expensive half
231
+ is the assertion whose real condition slid out of the condition slot, because it
232
+ was a working assertion when it was typed:
233
+
234
+ ```python
235
+ assert { # ← braces, not parentheses
236
+ "referencing a non existing `via_device` " in caplog.text
237
+ } # one-element SET, always truthy
238
+
239
+ assert [f"No logs found on hdfs for ti={ti}"] # the `== messages` was lost
240
+ assert True, cover_result_json[0]["success"][...] # slid into the MESSAGE slot
241
+ ```
242
+
243
+ **The narrowness is the rule.** The obvious generalisation — "flag a comparison
244
+ of a thing with itself" — measures ~95% false positives: `assert i == i`,
245
+ `assert x is x`, `expect(hash([o])).toEqual(hash([o]))` are reflexivity,
246
+ determinism and memoization tests, and for a type with custom `__eq__`/`__hash__`
247
+ they can genuinely fail. So an identifier, attribute or call operand is never
248
+ enough; both sides must be literals, and textually identical ones. `assert True`
249
+ as the sole statement of an `except` handler is exempt — it asserts *which branch
250
+ ran* — as is anything inside a pytest-benchmark test.
251
+
252
+ Measured before shipping: **4 findings across 28,608 files** — 26,346 of
253
+ pydantic, trio, attrs, Airflow and Home Assistant plus 2,262 first-party files
254
+ in bulbul, noura-be, kpi-hub, ai and demo-gateway. All 4 are true positives
255
+ (Home Assistant `tests/helpers/test_device_registry.py:3711` and `:3777`,
256
+ `tests/components/emulated_hue/test_hue_api.py:1078`, Airflow
257
+ `providers/apache/hdfs/.../log/test_hdfs_task_handler.py:170`); 0 false
258
+ positives. The two `except ...: assert True` markers that a carve-out-free
259
+ version does flag — `pydantic-core/tests/benchmarks/test_micro_benchmarks.py:716`
260
+ and `core/tests/components/mqtt/test_client.py:1353` — were verified silent.
261
+
262
+ The TypeScript half of the same rule ships as `@sarj/no-tautological-expect` in
263
+ `@sarj/eslint-plugin` ≥ 2.14.0; until now there was no TS counterpart at all,
264
+ which is how `expect(true).toBe(true); // placeholder` survived in a suite named
265
+ for the behaviour it was supposed to check.
266
+
267
+ ## CLI
268
+
269
+ ```bash
270
+ sarj-python-lint check --rule no-sequential-await path/to/file.py
271
+ sarj-python-lint list-rules
272
+ ```
273
+
274
+ Diagnostic format is `path:line:col: CODE message` — Ruff-compatible.
275
+
276
+ ## Suppression
277
+
278
+ Inline `# sarj-noqa: SARJ00X — <reason>` on the offending line.
279
+
280
+ Each rule's source under `src/sarj_python_lint/rules/` carries its own `description` and diagnostic message.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.20.0"
3
+ version = "0.23.0"
4
4
  description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "sarj-ai" }]
@@ -18,6 +18,7 @@ dependencies = []
18
18
 
19
19
  [project.scripts]
20
20
  sarj-python-lint = "sarj_python_lint.__main__:main"
21
+ sarj-ratchet = "sarj_python_lint._ratchet_cli:main"
21
22
 
22
23
  [project.urls]
23
24
  Homepage = "https://github.com/sarj-ai/standards/tree/main/packages/python"