sarj-python-lint 0.21.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.
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/PKG-INFO +87 -1
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/README.md +86 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/pyproject.toml +1 -1
- sarj_python_lint-0.23.0/src/sarj_python_lint/rules/_pytest.py +61 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/_registry.py +19 -0
- sarj_python_lint-0.23.0/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +192 -0
- sarj_python_lint-0.23.0/src/sarj_python_lint/rules/no_tautological_expect.py +374 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/zero_assertion_test.py +2 -13
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/.gitignore +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sarj-python-lint
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.23.0
|
|
4
4
|
Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
|
|
5
5
|
Project-URL: Homepage, https://github.com/sarj-ai/standards/tree/main/packages/python
|
|
6
6
|
Project-URL: Repository, https://github.com/sarj-ai/standards
|
|
@@ -196,6 +196,92 @@ sarj-python-lint check --rule mock-without-spec --update-baseline test-quality-b
|
|
|
196
196
|
sarj-python-lint check --rule mock-without-spec --baseline test-quality-baseline.json python/
|
|
197
197
|
```
|
|
198
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
|
+
|
|
199
285
|
## CLI
|
|
200
286
|
|
|
201
287
|
```bash
|
|
@@ -178,6 +178,92 @@ sarj-python-lint check --rule mock-without-spec --update-baseline test-quality-b
|
|
|
178
178
|
sarj-python-lint check --rule mock-without-spec --baseline test-quality-baseline.json python/
|
|
179
179
|
```
|
|
180
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
|
+
|
|
181
267
|
## CLI
|
|
182
268
|
|
|
183
269
|
```bash
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Shared pytest-shape predicates for the assertion-quality rules.
|
|
2
|
+
|
|
3
|
+
A pytest-benchmark test measures wall-clock time; verifying is not its job, and
|
|
4
|
+
every rule that judges a test's assertions needs the same carve-out for it —
|
|
5
|
+
SARJ043 (`zero-assertion-test`) because a benchmark asserts nothing, SARJ057
|
|
6
|
+
(`no-tautological-expect`) because the idiomatic way to benchmark a *failing*
|
|
7
|
+
path is `try: ...; assert False; except Err: assert True` inside the timed
|
|
8
|
+
callable. Keeping the predicate in one module is what stops the two copies from
|
|
9
|
+
drifting apart.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ast
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# The pytest-benchmark fixture: the test measures time, it does not verify.
|
|
18
|
+
_BENCHMARK = "benchmark"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def uses_benchmark_fixture(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
22
|
+
"""Report whether `node` both declares and uses the pytest-benchmark fixture.
|
|
23
|
+
|
|
24
|
+
`def test_x(benchmark)` then `benchmark(fn, arg)` or `@benchmark` on a nested
|
|
25
|
+
function. The fixture times the callable; a benchmark that asserted on the
|
|
26
|
+
result would be measuring the assertion. Requiring the name to be *used*, not
|
|
27
|
+
just declared, keeps an unrelated parameter that happens to be called
|
|
28
|
+
`benchmark` from silencing the rule.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
True when the function is a pytest-benchmark test.
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
args = node.args
|
|
35
|
+
declared = any(arg.arg == _BENCHMARK for arg in (*args.posonlyargs, *args.args, *args.kwonlyargs))
|
|
36
|
+
return declared and any(isinstance(child, ast.Name) and child.id == _BENCHMARK for child in ast.walk(node))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def has_benchmark_marker(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
40
|
+
"""Report whether `node` carries `@pytest.mark.benchmark`, called or bare.
|
|
41
|
+
|
|
42
|
+
The marker form is the other half of pytest-benchmark's surface: a test can
|
|
43
|
+
take the fixture, wear the marker, or both.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
True when a `pytest.mark.benchmark` decorator is present.
|
|
47
|
+
|
|
48
|
+
"""
|
|
49
|
+
return any(_decorator_attr(dec) == _BENCHMARK for dec in node.decorator_list)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _decorator_attr(dec: ast.expr) -> str | None:
|
|
53
|
+
target = dec.func if isinstance(dec, ast.Call) else dec
|
|
54
|
+
if not isinstance(target, ast.Attribute):
|
|
55
|
+
return None
|
|
56
|
+
# Only `*.mark.<name>`, so a `@benchmark.something` helper is not mistaken
|
|
57
|
+
# for a pytest marker.
|
|
58
|
+
parent = target.value
|
|
59
|
+
if isinstance(parent, ast.Attribute) and parent.attr == "mark":
|
|
60
|
+
return target.attr
|
|
61
|
+
return None
|
|
@@ -26,6 +26,9 @@ from sarj_python_lint.rules.no_fstring_in_log import NoFstringInLog
|
|
|
26
26
|
from sarj_python_lint.rules.no_gen_random_uuid_in_sql import NoGenRandomUuidInSql
|
|
27
27
|
from sarj_python_lint.rules.no_isinstance_union_chain import NoIsinstanceUnionChain
|
|
28
28
|
from sarj_python_lint.rules.no_offset_pagination import NoOffsetPagination
|
|
29
|
+
from sarj_python_lint.rules.no_optional_tenant_predicate import (
|
|
30
|
+
NoOptionalTenantPredicate,
|
|
31
|
+
)
|
|
29
32
|
from sarj_python_lint.rules.no_query_with_many_joins import NoQueryWithManyJoins
|
|
30
33
|
from sarj_python_lint.rules.no_raw_sql_in_tests import NoRawSqlInTests
|
|
31
34
|
from sarj_python_lint.rules.no_repeated_string_literal import NoRepeatedStringLiteral
|
|
@@ -36,6 +39,7 @@ from sarj_python_lint.rules.no_sentinel_return_on_except import NoSentinelReturn
|
|
|
36
39
|
from sarj_python_lint.rules.no_sequential_await import NoSequentialAwait
|
|
37
40
|
from sarj_python_lint.rules.no_sleep_in_test_body import NoSleepInTestBody
|
|
38
41
|
from sarj_python_lint.rules.no_stdlib_logging import NoStdlibLogging
|
|
42
|
+
from sarj_python_lint.rules.no_tautological_expect import NoTautologicalExpect
|
|
39
43
|
from sarj_python_lint.rules.no_unreachable_after_terminal import (
|
|
40
44
|
NoUnreachableAfterTerminal,
|
|
41
45
|
)
|
|
@@ -83,6 +87,19 @@ if TYPE_CHECKING:
|
|
|
83
87
|
# SARJ027, SARJ029, SARJ030 (dropped in 0.11.1 as too noisy),
|
|
84
88
|
# SARJ033 httpx-client-requires-timeout, SARJ035 no-import-time-settings
|
|
85
89
|
# (dropped by user veto after the 0.13.x mined-rules review),
|
|
90
|
+
# SARJ055 no-filler-success-adverb (built and corpus-validated, then dropped
|
|
91
|
+
# — but not for the reason first recorded. The headline "4.7% of Airflow's
|
|
92
|
+
# info/debug logs" is a composition artifact: 237 of those 248 hits are in
|
|
93
|
+
# `providers/` (contributed vendor operators, heavily copy-pasted — the
|
|
94
|
+
# template "%s completed successfully." appears 22 times verbatim). Airflow's
|
|
95
|
+
# maintainer-owned `airflow-core/` sits at 1.15% and Home Assistant at 1.24%,
|
|
96
|
+
# so the real external baseline is ~1.2%, not 4.7%. The rule was re-measured
|
|
97
|
+
# and dropped on a stronger basis: the narrow variant — fire only when the
|
|
98
|
+
# adverb is the sole content beyond a bare verb — has 12 external hits and
|
|
99
|
+
# ZERO internal ones across all six repos, so it would govern nothing we
|
|
100
|
+
# write. The broad rule remains opt-in house style, not a defect check.
|
|
101
|
+
# (Noted for any future revisit: noura-be measures 7.92%, a genuine outlier
|
|
102
|
+
# against every corpus; that, not the narrow variant, is the case to make.),
|
|
86
103
|
# SARJ037 no-trivial-single-use-helper (prototyped and dropped for FP rate;
|
|
87
104
|
# see the 0.13.1 inlining commit for the corpus analysis).
|
|
88
105
|
REGISTRY: dict[str, type[Rule]] = {
|
|
@@ -132,6 +149,8 @@ REGISTRY: dict[str, type[Rule]] = {
|
|
|
132
149
|
NoStdlibLogging.id: NoStdlibLogging,
|
|
133
150
|
NoGenRandomUuidInSql.id: NoGenRandomUuidInSql,
|
|
134
151
|
NoFileLevelEscapeHatchNoqa.id: NoFileLevelEscapeHatchNoqa,
|
|
152
|
+
NoOptionalTenantPredicate.id: NoOptionalTenantPredicate,
|
|
153
|
+
NoTautologicalExpect.id: NoTautologicalExpect,
|
|
135
154
|
}
|
|
136
155
|
|
|
137
156
|
__all__ = ["REGISTRY"]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""SARJ056: a tenant predicate that only appears inside a conditional branch.
|
|
2
|
+
|
|
3
|
+
Multi-tenant stores compose a WHERE clause by accumulating fragments in a list::
|
|
4
|
+
|
|
5
|
+
where_conditions: list[Composable] = []
|
|
6
|
+
if args.organization_ids:
|
|
7
|
+
where_conditions.append(SQL("organization_id = ANY(%s::uuid[])"))
|
|
8
|
+
if args.status:
|
|
9
|
+
where_conditions.append(SQL("status = ANY(%s)"))
|
|
10
|
+
where_clause = SQL(" AND ").join(where_conditions) if where_conditions else SQL("1=1")
|
|
11
|
+
|
|
12
|
+
When the tenant fragment is the *only* thing standing between a caller and every
|
|
13
|
+
other tenant's rows, guarding it with `if` makes the scoping **fail open**: the
|
|
14
|
+
query still executes, just without the predicate. A caller that passes an empty
|
|
15
|
+
or missing organization list silently reads the whole table.
|
|
16
|
+
|
|
17
|
+
This is not hypothetical. In bulbul, `PsqlCallStore._build_filter_conditions`
|
|
18
|
+
had exactly this shape, and `POST /v1/calls/list` reached it with
|
|
19
|
+
`organization_ids=[]` for any user whose `organization_id` was NULL — composing
|
|
20
|
+
`SELECT ... FROM call WHERE 1=1`, i.e. every tenant's calls.
|
|
21
|
+
|
|
22
|
+
The rule fires when, within a single function, *every* WHERE-fragment that
|
|
23
|
+
mentions a tenant column is nested inside a conditional. The safe idiom seeds
|
|
24
|
+
the fragment list with the tenant predicate unconditionally::
|
|
25
|
+
|
|
26
|
+
conditions: list[Composable] = [SQL("organization_id = %s")] # always applied
|
|
27
|
+
|
|
28
|
+
so that form never fires. A function with no tenant fragment at all does not
|
|
29
|
+
fire either — an intentionally cross-tenant admin query is not this rule's
|
|
30
|
+
business; only *attempted-but-optional* scoping is.
|
|
31
|
+
|
|
32
|
+
Scope note: only fragments participating in list composition (a list literal, or
|
|
33
|
+
an argument to `.append()` / `.extend()`) are considered, so an unrelated inline
|
|
34
|
+
`WHERE organization_id = %s` elsewhere in the same function neither triggers nor
|
|
35
|
+
masks a finding.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import ast
|
|
41
|
+
import re
|
|
42
|
+
from typing import TYPE_CHECKING, override
|
|
43
|
+
|
|
44
|
+
from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
|
|
45
|
+
from sarj_python_lint.rules._paths import is_test_path
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Columns that scope a row to a tenant. `org_id` is included because bulbul's
|
|
53
|
+
# older stores use it; `tenant_id`/`account_id`/`workspace_id` are the common
|
|
54
|
+
# names the same pattern takes in other multi-tenant codebases.
|
|
55
|
+
_TENANT_COLUMNS = ("organization_id", "org_id", "tenant_id", "account_id", "workspace_id")
|
|
56
|
+
|
|
57
|
+
# A fragment is a predicate (not a bare column name in a SELECT list) when the
|
|
58
|
+
# tenant column is followed by a comparison. `= %s`, `= ANY(...)`, `IN (...)`,
|
|
59
|
+
# `= {}` (psycopg SQL.format) and `<>` all count.
|
|
60
|
+
_TENANT_PREDICATE_RE = re.compile(
|
|
61
|
+
r"\b(?:\w+\.)?(?:" + "|".join(_TENANT_COLUMNS) + r")\b\s*(?:=|<>|!=|\bIN\b|\bIS\b)",
|
|
62
|
+
re.IGNORECASE,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
_CONDITIONAL_NODES = (ast.If, ast.IfExp)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class NoOptionalTenantPredicate(Rule):
|
|
69
|
+
"""Tenant scoping that disappears when a filter is absent."""
|
|
70
|
+
|
|
71
|
+
id: str = "no-optional-tenant-predicate"
|
|
72
|
+
code: str = "SARJ056"
|
|
73
|
+
description: str = (
|
|
74
|
+
"A tenant predicate reachable only inside a conditional makes tenant scoping fail open — "
|
|
75
|
+
"the query still runs, unscoped, when the filter is absent."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
@override
|
|
79
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
80
|
+
if is_test_path(path):
|
|
81
|
+
return []
|
|
82
|
+
# A file with no tenant column anywhere cannot produce a finding, and
|
|
83
|
+
# that is the overwhelming majority of files. Skipping the AST walk
|
|
84
|
+
# here keeps the rule off the critical path of every unrelated file.
|
|
85
|
+
if not any(column in source for column in _TENANT_COLUMNS):
|
|
86
|
+
return []
|
|
87
|
+
tree = parse_or_none(path, source)
|
|
88
|
+
if tree is None:
|
|
89
|
+
return []
|
|
90
|
+
|
|
91
|
+
diags: list[Diagnostic] = []
|
|
92
|
+
for func in _iter_functions(tree):
|
|
93
|
+
fragments = _tenant_fragments(func)
|
|
94
|
+
if not fragments:
|
|
95
|
+
continue
|
|
96
|
+
if any(not conditional for _, conditional in fragments):
|
|
97
|
+
# At least one unconditional tenant predicate — scoping always applies.
|
|
98
|
+
continue
|
|
99
|
+
node = fragments[0][0]
|
|
100
|
+
diags.append(
|
|
101
|
+
Diagnostic(
|
|
102
|
+
path=path,
|
|
103
|
+
line=node.lineno,
|
|
104
|
+
col=node.col_offset + 1,
|
|
105
|
+
code=self.code,
|
|
106
|
+
message=(
|
|
107
|
+
f"tenant predicate in `{func.name}` is only added inside a conditional, so the "
|
|
108
|
+
"query runs unscoped when the filter is empty or missing. Seed the condition "
|
|
109
|
+
"list with the tenant predicate unconditionally, or require the tenant id."
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
diags.sort(key=lambda d: (d.line, d.col))
|
|
114
|
+
return diags
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _iter_functions(tree: ast.Module) -> list[ast.FunctionDef | ast.AsyncFunctionDef]:
|
|
118
|
+
"""Collect every function and method in the module.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
All `FunctionDef`/`AsyncFunctionDef` nodes, outermost first.
|
|
122
|
+
|
|
123
|
+
"""
|
|
124
|
+
return [n for n in ast.walk(tree) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _tenant_fragments(func: ast.FunctionDef | ast.AsyncFunctionDef) -> list[tuple[ast.expr, bool]]:
|
|
128
|
+
"""Find WHERE-fragments in `func` that carry a tenant predicate.
|
|
129
|
+
|
|
130
|
+
Only fragments taking part in list composition count: elements of a list
|
|
131
|
+
literal, or arguments to `.append()` / `.extend()`. Each is paired with
|
|
132
|
+
whether it sits inside a conditional *within this function*.
|
|
133
|
+
|
|
134
|
+
One recursive pass carries the "am I under an `If`/`IfExp`?" flag down the
|
|
135
|
+
tree, so the cost is linear in the function's node count.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
`(node, is_conditional)` pairs in source order.
|
|
139
|
+
|
|
140
|
+
"""
|
|
141
|
+
found: list[tuple[ast.expr, bool]] = []
|
|
142
|
+
|
|
143
|
+
def visit(node: ast.AST, *, conditional: bool) -> None:
|
|
144
|
+
# An `IfExp` fragment guards the predicate inside itself:
|
|
145
|
+
# `c.append(SQL("organization_id = %s") if org else SQL("TRUE"))`.
|
|
146
|
+
found.extend(
|
|
147
|
+
(fragment, conditional or isinstance(fragment, ast.IfExp))
|
|
148
|
+
for fragment in _composition_fragments(node)
|
|
149
|
+
if _mentions_tenant_predicate(fragment)
|
|
150
|
+
)
|
|
151
|
+
nested = conditional or isinstance(node, _CONDITIONAL_NODES)
|
|
152
|
+
for child in ast.iter_child_nodes(node):
|
|
153
|
+
visit(child, conditional=nested)
|
|
154
|
+
|
|
155
|
+
for child in ast.iter_child_nodes(func):
|
|
156
|
+
visit(child, conditional=False)
|
|
157
|
+
found.sort(key=lambda pair: (pair[0].lineno, pair[0].col_offset))
|
|
158
|
+
return found
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _composition_fragments(node: ast.AST) -> list[ast.expr]:
|
|
162
|
+
"""Yield the expressions `node` itself accumulates into a WHERE-fragment list.
|
|
163
|
+
|
|
164
|
+
Shallow by design — the caller recurses — so each node is inspected once.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
List-literal elements, or `.append()`/`.extend()` arguments.
|
|
168
|
+
|
|
169
|
+
"""
|
|
170
|
+
if isinstance(node, ast.List):
|
|
171
|
+
return list(node.elts)
|
|
172
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in {"append", "extend"}:
|
|
173
|
+
return list(node.args)
|
|
174
|
+
return []
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _mentions_tenant_predicate(node: ast.expr) -> bool:
|
|
178
|
+
"""Report whether `node`'s subtree contains a tenant-column predicate string.
|
|
179
|
+
|
|
180
|
+
Walking the subtree catches `SQL("organization_id = %s")` and the
|
|
181
|
+
`SQL("...").format(...)` form alike.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
True when a tenant predicate literal appears in the subtree.
|
|
185
|
+
|
|
186
|
+
"""
|
|
187
|
+
return any(
|
|
188
|
+
isinstance(child, ast.Constant)
|
|
189
|
+
and isinstance(child.value, str)
|
|
190
|
+
and _TENANT_PREDICATE_RE.search(child.value) is not None
|
|
191
|
+
for child in ast.walk(node)
|
|
192
|
+
)
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""SARJ057: an assertion whose outcome is decided by the literal it was handed.
|
|
2
|
+
|
|
3
|
+
`assert True`, `assert ["..."]`, `self.assertEqual(1, 1)` — the condition
|
|
4
|
+
contains no value the code under test produced, so the assertion passes before
|
|
5
|
+
the program runs. It is not a weak test, it is a *non*-test: deleting the
|
|
6
|
+
function under test entirely would not change the result.
|
|
7
|
+
|
|
8
|
+
Two shapes dominate, and neither looks wrong at a glance:
|
|
9
|
+
|
|
10
|
+
* **the placeholder that was never replaced** — `expect(true).toBe(true)` in
|
|
11
|
+
TypeScript, `assert True` in Python, left behind when a test file was
|
|
12
|
+
scaffolded and the body never written;
|
|
13
|
+
* **the assertion whose real condition slid out of the condition slot.** This is
|
|
14
|
+
the dangerous one, because it was a working assertion when it was typed. Home
|
|
15
|
+
Assistant has the same six-line condition twice in
|
|
16
|
+
`tests/helpers/test_device_registry.py` (:3711, :3777), wrapped in braces
|
|
17
|
+
rather than parentheses::
|
|
18
|
+
|
|
19
|
+
assert {
|
|
20
|
+
"calls `device_registry.async_get_or_create` "
|
|
21
|
+
"referencing a non existing `via_device` " in caplog.text
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
The braces make it a one-element **set display**, which is truthy whatever the
|
|
25
|
+
`in` test inside it evaluates to. Airflow has the list-literal spelling in
|
|
26
|
+
`providers/apache/hdfs/.../log/test_hdfs_task_handler.py:170`,
|
|
27
|
+
`assert [f"No logs found on hdfs for ti={ti}"]`, where the `== messages` was
|
|
28
|
+
lost. And Home Assistant's `emulated_hue/test_hue_api.py:1078` shows the third
|
|
29
|
+
variant, where the value slid into the assertion-*message* slot:
|
|
30
|
+
`assert True, cover_result_json[0]["success"][...]` — a `KeyError` there would
|
|
31
|
+
still be raised, so it half-works, which is why it survived review.
|
|
32
|
+
|
|
33
|
+
Fires on exactly four shapes, all of them syntactically decidable:
|
|
34
|
+
|
|
35
|
+
1. `assert <always-truthy literal>` — `True`, a nonzero number, a non-empty
|
|
36
|
+
string, or a non-empty list/set/dict/tuple **display**;
|
|
37
|
+
2. `assert <literal> == <textually identical literal>` (and `is`);
|
|
38
|
+
3. `assertTrue(<truthy literal>)` / `assertFalse(<falsy literal>)`;
|
|
39
|
+
4. `assertEqual(<literal>, <textually identical literal>)` (and `assertIs`).
|
|
40
|
+
|
|
41
|
+
**The narrowness is the rule.** The obvious generalisation — "flag a comparison
|
|
42
|
+
of a thing with itself" — was measured and is ~95% false positives.
|
|
43
|
+
`assert i == i`, `assert x is x`, `expect(hash([o])).toEqual(hash([o]))` are
|
|
44
|
+
*reflexivity, determinism and memoization* tests: for a type with a custom
|
|
45
|
+
`__eq__` or `__hash__`, `x == x` is precisely the property under test and can
|
|
46
|
+
genuinely fail. So an operand that is an identifier, an attribute or a call is
|
|
47
|
+
never enough; both sides must be literals, and textually identical ones.
|
|
48
|
+
|
|
49
|
+
Deliberately NOT flagged:
|
|
50
|
+
|
|
51
|
+
* **`assert True` as the sole statement of an `except` handler** — the
|
|
52
|
+
deliberate "reaching here is the acceptable outcome" marker, the mirror image
|
|
53
|
+
of the `assert False` that precedes it in the `try`. It reads as a tautology
|
|
54
|
+
in isolation and is a real assertion in context: it asserts *which branch ran*.
|
|
55
|
+
Both known Python false positives are this shape —
|
|
56
|
+
`pydantic-core/tests/benchmarks/test_micro_benchmarks.py:716` and
|
|
57
|
+
`core/tests/components/mqtt/test_client.py:1353`;
|
|
58
|
+
* **anything inside a pytest-benchmark test**, whether it takes the `benchmark`
|
|
59
|
+
fixture or wears `@pytest.mark.benchmark` — the same carve-out SARJ043 needs,
|
|
60
|
+
shared through `_pytest.py`. The try/`assert False`/except/`assert True`
|
|
61
|
+
sandwich above is the idiomatic way to time a *failing* validation path;
|
|
62
|
+
* `assert False` — the standard unreachable-branch marker, and an assertion that
|
|
63
|
+
always fails is a loud problem, not a silent one;
|
|
64
|
+
* an empty container (`assert []`), which is always *falsy* — that is a failing
|
|
65
|
+
assertion, again loud;
|
|
66
|
+
* a container display with a `*splat` or `**unpack` element (`assert [*items]`),
|
|
67
|
+
whose emptiness depends on the runtime value;
|
|
68
|
+
* an f-string, whose truth depends on the interpolated values.
|
|
69
|
+
|
|
70
|
+
Measured before shipping: **4 findings across 28,608 files** — 26,346 of
|
|
71
|
+
pydantic, trio, attrs, Airflow and Home Assistant plus 2,262 first-party files
|
|
72
|
+
in bulbul, noura-be, kpi-hub, ai and demo-gateway. All 4 are the true positives
|
|
73
|
+
named above; 0 false positives. The `except`/benchmark carve-outs are
|
|
74
|
+
load-bearing rather than defensive: with `_exempt_nodes` neutered the sweep
|
|
75
|
+
gains exactly the two known false positives and nothing else.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
from __future__ import annotations
|
|
79
|
+
|
|
80
|
+
import ast
|
|
81
|
+
from typing import TYPE_CHECKING, override
|
|
82
|
+
|
|
83
|
+
from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
|
|
84
|
+
from sarj_python_lint.rules._pytest import has_benchmark_marker, uses_benchmark_fixture
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if TYPE_CHECKING:
|
|
88
|
+
from pathlib import Path
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_FUNC_NODES = (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
92
|
+
|
|
93
|
+
# unittest methods whose single argument fixes the outcome on its own.
|
|
94
|
+
_TRUTHY_ARG_METHODS = frozenset({"assertTrue"})
|
|
95
|
+
_FALSY_ARG_METHODS = frozenset({"assertFalse"})
|
|
96
|
+
|
|
97
|
+
# unittest methods that compare two operands for sameness.
|
|
98
|
+
_EQUALITY_METHODS = frozenset({"assertEqual", "assertEquals", "assertIs"})
|
|
99
|
+
|
|
100
|
+
# unittest's failure-text parameter — present or not, the outcome is the same.
|
|
101
|
+
_UNITTEST_MSG_KWARG = "msg"
|
|
102
|
+
|
|
103
|
+
# `assertEqual(first, second)` and friends: the two operands compared.
|
|
104
|
+
_EQUALITY_ARITY = 2
|
|
105
|
+
|
|
106
|
+
# Comparison operators whose two-identical-literals form is a tautology. `<=`
|
|
107
|
+
# and `>=` are too, but nobody writes them by accident; `!=`/`is not` on
|
|
108
|
+
# identical literals always *fails*, which is loud rather than silent.
|
|
109
|
+
_SAMENESS_OPS = (ast.Eq, ast.Is)
|
|
110
|
+
|
|
111
|
+
# Enough of the operand to identify it in the message without pasting a screenful.
|
|
112
|
+
_OPERAND_PREVIEW_CHARS = 40
|
|
113
|
+
|
|
114
|
+
_CONTAINER_KINDS: dict[type[ast.expr], str] = {
|
|
115
|
+
ast.List: "list",
|
|
116
|
+
ast.Set: "set",
|
|
117
|
+
ast.Dict: "dict",
|
|
118
|
+
ast.Tuple: "tuple",
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class NoTautologicalExpect(Rule):
|
|
123
|
+
"""An assertion on a literal can never fail — it tests the literal, not the code."""
|
|
124
|
+
|
|
125
|
+
id: str = "no-tautological-expect"
|
|
126
|
+
code: str = "SARJ057"
|
|
127
|
+
description: str = "Assertion whose operands are all literals — its outcome is fixed before the code runs."
|
|
128
|
+
|
|
129
|
+
@override
|
|
130
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
131
|
+
"""Flag assertions whose truth is decided by their own literals.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
One diagnostic per never-failing assertion, sorted by position.
|
|
135
|
+
|
|
136
|
+
"""
|
|
137
|
+
tree = parse_or_none(path, source)
|
|
138
|
+
if tree is None:
|
|
139
|
+
return []
|
|
140
|
+
exempt = _exempt_nodes(tree)
|
|
141
|
+
diags = [
|
|
142
|
+
Diagnostic(
|
|
143
|
+
path=path,
|
|
144
|
+
line=node.lineno,
|
|
145
|
+
col=node.col_offset + 1,
|
|
146
|
+
code=self.code,
|
|
147
|
+
message=_message(node, reason),
|
|
148
|
+
)
|
|
149
|
+
for node, reason in _tautologies(tree, exempt)
|
|
150
|
+
]
|
|
151
|
+
diags.sort(key=lambda d: (d.line, d.col))
|
|
152
|
+
return diags
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _exempt_nodes(tree: ast.Module) -> set[ast.AST]:
|
|
156
|
+
"""Collect the nodes the carve-outs put out of reach.
|
|
157
|
+
|
|
158
|
+
Every node under a pytest-benchmark test, plus the lone `assert` that forms
|
|
159
|
+
an `except` handler's whole body — the "this exception is the acceptable
|
|
160
|
+
outcome" marker, which is a statement about control flow rather than about a
|
|
161
|
+
literal.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
The nodes this rule must not report.
|
|
165
|
+
|
|
166
|
+
"""
|
|
167
|
+
exempt: set[ast.AST] = set()
|
|
168
|
+
for node in ast.walk(tree):
|
|
169
|
+
if isinstance(node, _FUNC_NODES) and (uses_benchmark_fixture(node) or has_benchmark_marker(node)):
|
|
170
|
+
exempt.update(ast.walk(node))
|
|
171
|
+
elif isinstance(node, ast.ExceptHandler) and len(node.body) == 1 and isinstance(node.body[0], ast.Assert):
|
|
172
|
+
exempt.add(node.body[0])
|
|
173
|
+
return exempt
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _tautologies(tree: ast.Module, exempt: set[ast.AST]) -> list[tuple[ast.Assert | ast.Call, str]]:
|
|
177
|
+
"""Find every assertion in `tree` whose outcome its own literals decide.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Pairs of offending node and the phrase describing why it cannot fail.
|
|
181
|
+
|
|
182
|
+
"""
|
|
183
|
+
found: list[tuple[ast.Assert | ast.Call, str]] = []
|
|
184
|
+
for node in ast.walk(tree):
|
|
185
|
+
if not isinstance(node, (ast.Assert, ast.Call)) or node in exempt:
|
|
186
|
+
continue
|
|
187
|
+
reason = _fixed_truth_reason(node.test) if isinstance(node, ast.Assert) else _unittest_reason(node)
|
|
188
|
+
if reason is not None:
|
|
189
|
+
found.append((node, reason))
|
|
190
|
+
return found
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _unittest_reason(node: ast.Call) -> str | None:
|
|
194
|
+
"""Describe why a `unittest` assertion call cannot fail, if it cannot.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
The reason phrase, or None when the call is not a fixed-outcome assertion.
|
|
198
|
+
|
|
199
|
+
"""
|
|
200
|
+
name = _called_method(node)
|
|
201
|
+
# `msg=` is unittest's failure text and changes nothing about the outcome;
|
|
202
|
+
# any other keyword means this is not the method we think it is.
|
|
203
|
+
if name is None or any(kw.arg != _UNITTEST_MSG_KWARG for kw in node.keywords):
|
|
204
|
+
return None
|
|
205
|
+
args = node.args
|
|
206
|
+
if name in _EQUALITY_METHODS and len(args) >= _EQUALITY_ARITY and _is_same_literal(args[0], args[1]):
|
|
207
|
+
return f"`{_preview(args[0])}` is compared with an identical literal"
|
|
208
|
+
if len(args) < 1:
|
|
209
|
+
return None
|
|
210
|
+
if name in _TRUTHY_ARG_METHODS:
|
|
211
|
+
return _fixed_truth_reason(args[0])
|
|
212
|
+
if name in _FALSY_ARG_METHODS and _is_always_falsy_literal(args[0]):
|
|
213
|
+
return f"`{_preview(args[0])}` is a literal that is always falsy"
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _fixed_truth_reason(test: ast.expr) -> str | None:
|
|
218
|
+
"""Describe why `test` is always truthy, if it is.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
The reason phrase, or None when the value depends on the code.
|
|
222
|
+
|
|
223
|
+
"""
|
|
224
|
+
if _constant_truth(test) is True:
|
|
225
|
+
return f"`{_preview(test)}` is a constant truthy value"
|
|
226
|
+
kind = _nonempty_container_kind(test)
|
|
227
|
+
if kind is not None:
|
|
228
|
+
return f"a non-empty {kind} display is truthy whatever it contains"
|
|
229
|
+
if _is_identical_literal_comparison(test):
|
|
230
|
+
return f"`{_preview(test)}` compares a literal with an identical literal"
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _constant_truth(node: ast.expr) -> bool | None:
|
|
235
|
+
"""Evaluate the truthiness of a scalar constant, `-1` and `+0` included.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
The constant's truth value, or None when `node` is not a scalar constant.
|
|
239
|
+
|
|
240
|
+
"""
|
|
241
|
+
if isinstance(node, ast.Constant):
|
|
242
|
+
return bool(node.value)
|
|
243
|
+
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
|
|
244
|
+
return _constant_truth(node.operand)
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _nonempty_container_kind(node: ast.expr) -> str | None:
|
|
249
|
+
"""Name the container display kind when `node` is a provably non-empty one.
|
|
250
|
+
|
|
251
|
+
A `*splat`/`**unpack` element makes emptiness a runtime question, so those
|
|
252
|
+
are excluded — `assert [*items]` really can fail.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
"list"/"set"/"dict"/"tuple", or None.
|
|
256
|
+
|
|
257
|
+
"""
|
|
258
|
+
if isinstance(node, ast.Dict):
|
|
259
|
+
if not node.keys or any(key is None for key in node.keys):
|
|
260
|
+
return None
|
|
261
|
+
return _CONTAINER_KINDS[ast.Dict]
|
|
262
|
+
if not isinstance(node, (ast.List, ast.Set, ast.Tuple)):
|
|
263
|
+
return None
|
|
264
|
+
if not node.elts or any(isinstance(elt, ast.Starred) for elt in node.elts):
|
|
265
|
+
return None
|
|
266
|
+
return _CONTAINER_KINDS[type(node)]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _is_identical_literal_comparison(node: ast.expr) -> bool:
|
|
270
|
+
"""Report whether `node` compares one literal with a textually identical one.
|
|
271
|
+
|
|
272
|
+
Single-operator comparisons only, and only `==`/`is`. Both operands must be
|
|
273
|
+
literals: `assert i == i` and `assert x is x` are reflexivity tests on a
|
|
274
|
+
real object and are the false positives this rule exists to avoid.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
True for `1 == 1`, `"a" is "a"`; False for `i == i`, `f(x) == f(x)`.
|
|
278
|
+
|
|
279
|
+
"""
|
|
280
|
+
if not isinstance(node, ast.Compare) or len(node.ops) != 1:
|
|
281
|
+
return False
|
|
282
|
+
if not isinstance(node.ops[0], _SAMENESS_OPS):
|
|
283
|
+
return False
|
|
284
|
+
return _is_same_literal(node.left, node.comparators[0])
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _is_same_literal(left: ast.expr, right: ast.expr) -> bool:
|
|
288
|
+
"""Report whether both operands are literals with identical syntax.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
True when both are literals and unparse to the same source.
|
|
292
|
+
|
|
293
|
+
"""
|
|
294
|
+
return _is_literal(left) and _is_literal(right) and ast.dump(left) == ast.dump(right)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _is_literal(node: ast.expr) -> bool:
|
|
298
|
+
"""Report whether `node` is a literal built entirely from constants.
|
|
299
|
+
|
|
300
|
+
An identifier, attribute, call or f-string is not — its value comes from
|
|
301
|
+
somewhere the syntax cannot see, which is exactly what makes an assertion on
|
|
302
|
+
it a real assertion.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
True for constants, negated numeric constants, and displays of literals.
|
|
306
|
+
|
|
307
|
+
"""
|
|
308
|
+
if isinstance(node, ast.Constant):
|
|
309
|
+
return True
|
|
310
|
+
if isinstance(node, ast.UnaryOp):
|
|
311
|
+
# `-1` is a negation of a constant, not a constant; without this,
|
|
312
|
+
# `assertEqual(-1, -1)` would slip through.
|
|
313
|
+
return isinstance(node.op, (ast.USub, ast.UAdd)) and _is_literal(node.operand)
|
|
314
|
+
if isinstance(node, (ast.List, ast.Set, ast.Tuple)):
|
|
315
|
+
return all(_is_literal(elt) for elt in node.elts)
|
|
316
|
+
if isinstance(node, ast.Dict):
|
|
317
|
+
return all(key is not None and _is_literal(key) for key in node.keys) and all(
|
|
318
|
+
_is_literal(value) for value in node.values
|
|
319
|
+
)
|
|
320
|
+
return False
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _is_always_falsy_literal(node: ast.expr) -> bool:
|
|
324
|
+
"""Report whether `node` is a literal that is always falsy.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
True for `False`, `0`, `""`, `None` and empty displays.
|
|
328
|
+
|
|
329
|
+
"""
|
|
330
|
+
if _constant_truth(node) is False:
|
|
331
|
+
return True
|
|
332
|
+
if isinstance(node, (ast.List, ast.Tuple)):
|
|
333
|
+
return not node.elts
|
|
334
|
+
if isinstance(node, ast.Dict):
|
|
335
|
+
return not node.keys
|
|
336
|
+
return False
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _called_method(node: ast.Call) -> str | None:
|
|
340
|
+
func = node.func
|
|
341
|
+
if isinstance(func, ast.Attribute):
|
|
342
|
+
return func.attr
|
|
343
|
+
if isinstance(func, ast.Name):
|
|
344
|
+
return func.id
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _preview(node: ast.expr) -> str:
|
|
349
|
+
"""Render `node` back to source, truncated so the message stays one line.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
The unparsed operand, elided past `_OPERAND_PREVIEW_CHARS`.
|
|
353
|
+
|
|
354
|
+
"""
|
|
355
|
+
text = " ".join(ast.unparse(node).split())
|
|
356
|
+
if len(text) > _OPERAND_PREVIEW_CHARS:
|
|
357
|
+
return f"{text[:_OPERAND_PREVIEW_CHARS]}…"
|
|
358
|
+
return text
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _message(node: ast.Assert | ast.Call, reason: str) -> str:
|
|
362
|
+
"""Compose the diagnostic, adding the message-slot hint where it applies.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
The full diagnostic message.
|
|
366
|
+
|
|
367
|
+
"""
|
|
368
|
+
slid_into_message_slot = isinstance(node, ast.Assert) and node.msg is not None
|
|
369
|
+
hint = (
|
|
370
|
+
" The expression you meant to assert on is sitting in the assertion-message slot — move it into the condition."
|
|
371
|
+
if slid_into_message_slot
|
|
372
|
+
else " Assert on a value the code produced, or delete the test."
|
|
373
|
+
)
|
|
374
|
+
return f"This assertion can never fail: {reason}.{hint}"
|
|
@@ -114,6 +114,7 @@ from typing import TYPE_CHECKING, override
|
|
|
114
114
|
|
|
115
115
|
from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
|
|
116
116
|
from sarj_python_lint.rules._paths import is_test_path
|
|
117
|
+
from sarj_python_lint.rules._pytest import uses_benchmark_fixture
|
|
117
118
|
|
|
118
119
|
|
|
119
120
|
if TYPE_CHECKING:
|
|
@@ -132,9 +133,6 @@ _RAISES_NAMES = frozenset({"raises", "warns", "fail"})
|
|
|
132
133
|
|
|
133
134
|
_TEST_PREFIX = "test_"
|
|
134
135
|
|
|
135
|
-
# The pytest-benchmark fixture: the test measures time, it does not verify.
|
|
136
|
-
_BENCHMARK = "benchmark"
|
|
137
|
-
|
|
138
136
|
# Fluent verification DSLs reached through an attribute rather than a call name.
|
|
139
137
|
_FLUENT_ATTRS = frozenset({"expect"})
|
|
140
138
|
|
|
@@ -203,7 +201,7 @@ def _unverifying_tests(tree: ast.Module) -> list[ast.FunctionDef | ast.AsyncFunc
|
|
|
203
201
|
verifying_helpers = _verifying_local_names(defined_here)
|
|
204
202
|
hits: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
|
|
205
203
|
for node in _collectible_tests(tree):
|
|
206
|
-
if _is_skipped(node) or _is_fixture(node) or _is_placeholder(node) or
|
|
204
|
+
if _is_skipped(node) or _is_fixture(node) or _is_placeholder(node) or uses_benchmark_fixture(node):
|
|
207
205
|
continue
|
|
208
206
|
if _verifies_something(node) or _delegates_verification(node, defined_here, verifying_helpers):
|
|
209
207
|
continue
|
|
@@ -292,15 +290,6 @@ def _is_inert(stmt: ast.stmt) -> bool:
|
|
|
292
290
|
return isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant)
|
|
293
291
|
|
|
294
292
|
|
|
295
|
-
def _uses_benchmark_fixture(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
296
|
-
# pytest-benchmark: `def test_x(benchmark)` then `benchmark(fn, arg)` or
|
|
297
|
-
# `@benchmark` on a nested function. The fixture times the callable; a
|
|
298
|
-
# benchmark that asserted on the result would be measuring the assertion.
|
|
299
|
-
args = node.args
|
|
300
|
-
declared = any(arg.arg == _BENCHMARK for arg in (*args.posonlyargs, *args.args, *args.kwonlyargs))
|
|
301
|
-
return declared and any(isinstance(child, ast.Name) and child.id == _BENCHMARK for child in ast.walk(node))
|
|
302
|
-
|
|
303
|
-
|
|
304
293
|
def _verifies_something(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
305
294
|
# Search the whole subtree, nested functions included: the common
|
|
306
295
|
# `async def _run(): assert ...` + `asyncio.run(_run())` wrapper keeps its
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/_first_party.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/mock_without_spec.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_comment_cruft.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_fstring_in_log.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_secret_in_log.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_select_star.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/no_stdlib_logging.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/prefer_class_row.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.21.0 → sarj_python_lint-0.23.0}/src/sarj_python_lint/rules/prefer_str_enum.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|