sarj-python-lint 0.13.1__tar.gz → 0.15.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.13.1 → sarj_python_lint-0.15.0}/PKG-INFO +27 -1
- sarj_python_lint-0.15.0/README.md +66 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/pyproject.toml +1 -1
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/_registry.py +24 -0
- sarj_python_lint-0.15.0/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +159 -0
- sarj_python_lint-0.15.0/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +144 -0
- sarj_python_lint-0.15.0/src/sarj_python_lint/rules/mock_without_spec.py +225 -0
- sarj_python_lint-0.15.0/src/sarj_python_lint/rules/no_file_level_suppression.py +228 -0
- sarj_python_lint-0.15.0/src/sarj_python_lint/rules/parametrize_case_needs_id.py +153 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +202 -0
- sarj_python_lint-0.15.0/src/sarj_python_lint/rules/prefer_module_level_constant.py +639 -0
- sarj_python_lint-0.15.0/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +156 -0
- sarj_python_lint-0.15.0/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +187 -0
- sarj_python_lint-0.15.0/src/sarj_python_lint/rules/xfail_requires_strict.py +170 -0
- sarj_python_lint-0.15.0/src/sarj_python_lint/rules/zero_assertion_test.py +190 -0
- sarj_python_lint-0.13.1/README.md +0 -40
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/.gitignore +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.15.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.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.15.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
|
|
@@ -42,6 +42,32 @@ uv tool install sarj-python-lint
|
|
|
42
42
|
- id: sarj-no-fstring-in-log
|
|
43
43
|
```
|
|
44
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
|
+
Adopting these against an existing suite is easier through the baseline ratchet
|
|
64
|
+
than as a big-bang fix — snapshot the current counts, then let them only shrink:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
sarj-python-lint check --rule mock-without-spec --update-baseline test-quality-baseline.json python/
|
|
68
|
+
sarj-python-lint check --rule mock-without-spec --baseline test-quality-baseline.json python/
|
|
69
|
+
```
|
|
70
|
+
|
|
45
71
|
## CLI
|
|
46
72
|
|
|
47
73
|
```bash
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
Adopting these against an existing suite is easier through the baseline ratchet
|
|
46
|
+
than as a big-bang fix — snapshot the current counts, then let them only shrink:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
sarj-python-lint check --rule mock-without-spec --update-baseline test-quality-baseline.json python/
|
|
50
|
+
sarj-python-lint check --rule mock-without-spec --baseline test-quality-baseline.json python/
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## CLI
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
sarj-python-lint check --rule no-sequential-await path/to/file.py
|
|
57
|
+
sarj-python-lint list-rules
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Diagnostic format is `path:line:col: CODE message` — Ruff-compatible.
|
|
61
|
+
|
|
62
|
+
## Suppression
|
|
63
|
+
|
|
64
|
+
Inline `# sarj-noqa: SARJ00X — <reason>` on the offending line.
|
|
65
|
+
|
|
66
|
+
Each rule's source under `src/sarj_python_lint/rules/` carries its own `description` and diagnostic message.
|
|
@@ -2,10 +2,13 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import TYPE_CHECKING
|
|
4
4
|
|
|
5
|
+
from sarj_python_lint.rules.fixture_returns_bare_tuple import FixtureReturnsBareTuple
|
|
5
6
|
from sarj_python_lint.rules.inefficient_string_concat_in_loop import (
|
|
6
7
|
InefficientStringConcatInLoop,
|
|
7
8
|
)
|
|
9
|
+
from sarj_python_lint.rules.kwarg_heavy_construction_in_test import KwargHeavyConstructionInTest
|
|
8
10
|
from sarj_python_lint.rules.kwonly_same_type_params import KwonlySameTypeParams
|
|
11
|
+
from sarj_python_lint.rules.mock_without_spec import MockWithoutSpec
|
|
9
12
|
from sarj_python_lint.rules.no_aggregation_in_store_query import (
|
|
10
13
|
NoAggregationInStoreQuery,
|
|
11
14
|
)
|
|
@@ -14,6 +17,7 @@ from sarj_python_lint.rules.no_cors_wildcard_with_credentials import (
|
|
|
14
17
|
NoCorsWildcardWithCredentials,
|
|
15
18
|
)
|
|
16
19
|
from sarj_python_lint.rules.no_fat_try_blocks import NoFatTryBlocks
|
|
20
|
+
from sarj_python_lint.rules.no_file_level_suppression import NoFileLevelSuppression
|
|
17
21
|
from sarj_python_lint.rules.no_fstring_in_log import NoFstringInLog
|
|
18
22
|
from sarj_python_lint.rules.no_isinstance_union_chain import NoIsinstanceUnionChain
|
|
19
23
|
from sarj_python_lint.rules.no_offset_pagination import NoOffsetPagination
|
|
@@ -28,11 +32,15 @@ from sarj_python_lint.rules.no_sleep_in_test_body import NoSleepInTestBody
|
|
|
28
32
|
from sarj_python_lint.rules.no_unreachable_after_terminal import (
|
|
29
33
|
NoUnreachableAfterTerminal,
|
|
30
34
|
)
|
|
35
|
+
from sarj_python_lint.rules.parametrize_case_needs_id import ParametrizeCaseNeedsId
|
|
31
36
|
from sarj_python_lint.rules.prefer_class_row import PreferClassRow
|
|
32
37
|
from sarj_python_lint.rules.prefer_constant_time_secret_compare import (
|
|
33
38
|
PreferConstantTimeSecretCompare,
|
|
34
39
|
)
|
|
35
40
|
from sarj_python_lint.rules.prefer_match_assert_never import PreferMatchAssertNever
|
|
41
|
+
from sarj_python_lint.rules.prefer_module_level_constant import (
|
|
42
|
+
PreferModuleLevelConstant,
|
|
43
|
+
)
|
|
36
44
|
from sarj_python_lint.rules.prefer_namedtuple_over_tuple_return import (
|
|
37
45
|
PreferNamedtupleOverTupleReturn,
|
|
38
46
|
)
|
|
@@ -45,10 +53,16 @@ from sarj_python_lint.rules.prefer_timedelta_for_durations import (
|
|
|
45
53
|
)
|
|
46
54
|
from sarj_python_lint.rules.pydantic_at_boundaries import PydanticAtBoundaries
|
|
47
55
|
from sarj_python_lint.rules.single_public_export import SinglePublicExport
|
|
56
|
+
from sarj_python_lint.rules.sleep_with_computed_arg_in_test import SleepWithComputedArgInTest
|
|
48
57
|
from sarj_python_lint.rules.stepdown import Stepdown
|
|
49
58
|
from sarj_python_lint.rules.store_insert_requires_on_conflict import (
|
|
50
59
|
StoreInsertRequiresOnConflict,
|
|
51
60
|
)
|
|
61
|
+
from sarj_python_lint.rules.test_loops_over_literal_cases import (
|
|
62
|
+
TestLoopsOverLiteralCases,
|
|
63
|
+
)
|
|
64
|
+
from sarj_python_lint.rules.xfail_requires_strict import XfailRequiresStrict
|
|
65
|
+
from sarj_python_lint.rules.zero_assertion_test import ZeroAssertionTest
|
|
52
66
|
|
|
53
67
|
|
|
54
68
|
if TYPE_CHECKING:
|
|
@@ -92,6 +106,16 @@ REGISTRY: dict[str, type[Rule]] = {
|
|
|
92
106
|
PreferMatchAssertNever.id: PreferMatchAssertNever,
|
|
93
107
|
KwonlySameTypeParams.id: KwonlySameTypeParams,
|
|
94
108
|
NoRawSqlInTests.id: NoRawSqlInTests,
|
|
109
|
+
NoFileLevelSuppression.id: NoFileLevelSuppression,
|
|
110
|
+
PreferModuleLevelConstant.id: PreferModuleLevelConstant,
|
|
111
|
+
MockWithoutSpec.id: MockWithoutSpec,
|
|
112
|
+
TestLoopsOverLiteralCases.id: TestLoopsOverLiteralCases,
|
|
113
|
+
ParametrizeCaseNeedsId.id: ParametrizeCaseNeedsId,
|
|
114
|
+
FixtureReturnsBareTuple.id: FixtureReturnsBareTuple,
|
|
115
|
+
KwargHeavyConstructionInTest.id: KwargHeavyConstructionInTest,
|
|
116
|
+
XfailRequiresStrict.id: XfailRequiresStrict,
|
|
117
|
+
SleepWithComputedArgInTest.id: SleepWithComputedArgInTest,
|
|
118
|
+
ZeroAssertionTest.id: ZeroAssertionTest,
|
|
95
119
|
}
|
|
96
120
|
|
|
97
121
|
__all__ = ["REGISTRY"]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""SARJ044: a fixture returning a bare tuple forces positional unpacking everywhere.
|
|
2
|
+
|
|
3
|
+
`return org, user` makes every consumer write `org, user = setup_orgs()` and
|
|
4
|
+
know the order by heart. Adding a third value silently shifts every call site's
|
|
5
|
+
meaning rather than breaking it loudly, and a mis-ordered unpack
|
|
6
|
+
(`user, org = ...`) type-checks fine and fails somewhere far away. A `NamedTuple`
|
|
7
|
+
or a small frozen dataclass names the fields, so consumers destructure by name,
|
|
8
|
+
new fields are additive, and the type checker catches a swap at the call site.
|
|
9
|
+
|
|
10
|
+
This is the house rule the codebase already applies to production code, applied
|
|
11
|
+
to fixtures. `CLAUDE.md` states it directly for ordinary functions — "No bare
|
|
12
|
+
multi-field tuples across a boundary... a `NamedTuple`... never a positional
|
|
13
|
+
`tuple[A, B]`" — and SARJ026 enforces it there. Fixtures were never covered,
|
|
14
|
+
which is exactly why they drifted.
|
|
15
|
+
|
|
16
|
+
Fires when ALL of these hold:
|
|
17
|
+
|
|
18
|
+
* the file is a test file, and the function carries a `@pytest.fixture` or
|
|
19
|
+
`@pytest_asyncio.fixture` decorator (in any spelling: bare, called, or
|
|
20
|
+
attribute-qualified),
|
|
21
|
+
* and the fixture's own body has a top-level `return`/`yield` of a **tuple
|
|
22
|
+
display** with at least two elements.
|
|
23
|
+
|
|
24
|
+
The nearest-enclosing-function check (the SARJ031 technique) is what makes this
|
|
25
|
+
safe: a factory fixture that returns a closure which itself returns a tuple is
|
|
26
|
+
attributed to the closure, not the fixture, and does not fire. That pattern is
|
|
27
|
+
common and legitimate — the tuple crosses the closure's boundary, not the
|
|
28
|
+
fixture's.
|
|
29
|
+
|
|
30
|
+
Deliberately NOT flagged:
|
|
31
|
+
|
|
32
|
+
* a `NamedTuple`, dataclass, or any other constructor call — those are
|
|
33
|
+
`ast.Call` nodes, never `ast.Tuple`, so the correct alternative can never be
|
|
34
|
+
mistaken for the smell,
|
|
35
|
+
* a single-element tuple — nothing to mis-order,
|
|
36
|
+
* a starred tuple (`return *pair, extra`) — the arity is not statically known,
|
|
37
|
+
* a tuple returned from a nested helper or closure inside the fixture.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import ast
|
|
43
|
+
from typing import TYPE_CHECKING, override
|
|
44
|
+
|
|
45
|
+
from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
|
|
46
|
+
from sarj_python_lint.rules._paths import is_test_path
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_FIXTURE = "fixture"
|
|
54
|
+
|
|
55
|
+
_MIN_FIELDS = 2
|
|
56
|
+
|
|
57
|
+
_FUNC_NODES = (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class FixtureReturnsBareTuple(Rule):
|
|
61
|
+
"""A fixture returning a bare tuple forces every consumer to unpack by position."""
|
|
62
|
+
|
|
63
|
+
id: str = "fixture-returns-bare-tuple"
|
|
64
|
+
code: str = "SARJ044"
|
|
65
|
+
description: str = (
|
|
66
|
+
"Fixture returns a bare multi-field tuple — return a NamedTuple so consumers destructure by name."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@override
|
|
70
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
71
|
+
"""Flag pytest fixtures whose own body returns or yields a bare tuple.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
One diagnostic per bare-tuple return, sorted by position.
|
|
75
|
+
|
|
76
|
+
"""
|
|
77
|
+
if not is_test_path(path):
|
|
78
|
+
return []
|
|
79
|
+
tree = parse_or_none(path, source)
|
|
80
|
+
if tree is None:
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
diags = [
|
|
84
|
+
Diagnostic(
|
|
85
|
+
path=path,
|
|
86
|
+
line=node.lineno,
|
|
87
|
+
col=node.col_offset + 1,
|
|
88
|
+
code=self.code,
|
|
89
|
+
message=(
|
|
90
|
+
f"this fixture hands back a bare {count}-field tuple, so every consumer unpacks it "
|
|
91
|
+
"positionally and a reorder fails silently. Return a `NamedTuple` (or a frozen "
|
|
92
|
+
"dataclass) so the fields are named."
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
for node, count in _bare_tuple_results(tree)
|
|
96
|
+
]
|
|
97
|
+
diags.sort(key=lambda d: (d.line, d.col))
|
|
98
|
+
return diags
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _bare_tuple_results(tree: ast.Module) -> list[tuple[ast.expr, int]]:
|
|
102
|
+
hits: list[tuple[ast.expr, int]] = []
|
|
103
|
+
for node in ast.walk(tree):
|
|
104
|
+
if not isinstance(node, _FUNC_NODES) or not _is_fixture(node):
|
|
105
|
+
continue
|
|
106
|
+
hits.extend(_tuple_results_of(node))
|
|
107
|
+
return hits
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _is_fixture(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
111
|
+
return any(_names_fixture(dec) for dec in node.decorator_list)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _names_fixture(dec: ast.expr) -> bool:
|
|
115
|
+
# `@pytest.fixture`, `@pytest.fixture(scope=...)`, `@fixture`, `@pytest_asyncio.fixture`.
|
|
116
|
+
target = dec.func if isinstance(dec, ast.Call) else dec
|
|
117
|
+
if isinstance(target, ast.Attribute):
|
|
118
|
+
return target.attr == _FIXTURE
|
|
119
|
+
return isinstance(target, ast.Name) and target.id == _FIXTURE
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _tuple_results_of(fixture: ast.FunctionDef | ast.AsyncFunctionDef) -> list[tuple[ast.expr, int]]:
|
|
123
|
+
found: list[tuple[ast.expr, int]] = []
|
|
124
|
+
for stmt in fixture.body:
|
|
125
|
+
found.extend(_scan_for_results(stmt))
|
|
126
|
+
return found
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _scan_for_results(node: ast.AST) -> list[tuple[ast.expr, int]]:
|
|
130
|
+
# Descend through control flow but never into a nested function: a tuple
|
|
131
|
+
# returned by a closure the fixture builds crosses the closure's boundary,
|
|
132
|
+
# not the fixture's, so it is a different (and legitimate) shape.
|
|
133
|
+
if isinstance(node, (*_FUNC_NODES, ast.Lambda)):
|
|
134
|
+
return []
|
|
135
|
+
found: list[tuple[ast.expr, int]] = []
|
|
136
|
+
value = _returned_value(node)
|
|
137
|
+
if value is not None:
|
|
138
|
+
count = _bare_tuple_arity(value)
|
|
139
|
+
if count >= _MIN_FIELDS:
|
|
140
|
+
found.append((value, count))
|
|
141
|
+
for child in ast.iter_child_nodes(node):
|
|
142
|
+
found.extend(_scan_for_results(child))
|
|
143
|
+
return found
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _returned_value(node: ast.AST) -> ast.expr | None:
|
|
147
|
+
if isinstance(node, ast.Return):
|
|
148
|
+
return node.value
|
|
149
|
+
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Yield):
|
|
150
|
+
return node.value.value
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _bare_tuple_arity(value: ast.expr) -> int:
|
|
155
|
+
if not isinstance(value, ast.Tuple):
|
|
156
|
+
return 0
|
|
157
|
+
if any(isinstance(elt, ast.Starred) for elt in value.elts):
|
|
158
|
+
return 0
|
|
159
|
+
return len(value.elts)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""SARJ045: a domain object built with many kwargs inline belongs in a builder.
|
|
2
|
+
|
|
3
|
+
A test that constructs `SarjBeneficiary(id=..., name=..., iban=..., bank=...,
|
|
4
|
+
status=..., created_at=..., updated_at=..., owner=..., currency=...)` in its own
|
|
5
|
+
body states nine facts, and typically only one of them is the thing under test.
|
|
6
|
+
The other eight are noise the reader must scan past to find the interesting
|
|
7
|
+
field, and every one of them has to be revisited when the model gains a required
|
|
8
|
+
column — across every test that spells the object out. A builder or factory with
|
|
9
|
+
defaults collapses that to `build_beneficiary(status="frozen")`, which says what
|
|
10
|
+
the test is about.
|
|
11
|
+
|
|
12
|
+
Fires when ALL of these hold:
|
|
13
|
+
|
|
14
|
+
* the file is a test file, and the **nearest enclosing function** of the call is
|
|
15
|
+
named `test_*`,
|
|
16
|
+
* and the call passes more than eight keyword arguments.
|
|
17
|
+
|
|
18
|
+
The nearest-enclosing-function guard is what makes this rule worth having rather
|
|
19
|
+
than noise. A blind sweep of both corpora found 113 kwarg-heavy constructions,
|
|
20
|
+
but 96 of them sit inside a module-level `_make_*`/`_build_*` helper — which is
|
|
21
|
+
precisely the factory this rule asks for, already written. Counting those would
|
|
22
|
+
have meant nagging at the well-factored code and rewarding the sloppy kind.
|
|
23
|
+
Scoped to calls directly in a test body, the population drops to 17.
|
|
24
|
+
|
|
25
|
+
The threshold is deliberately high. Eight keywords is well past the point where
|
|
26
|
+
a constructor call is self-explanatory, and it was chosen so the rule fires only
|
|
27
|
+
where the audited corpora showed a genuine builder was missing — in at least
|
|
28
|
+
three cases (`digital-bank/banking-ai/chat/tests/test_chat_store.py`) the fix is
|
|
29
|
+
a one-line import of a `build_sarj_beneficiary` helper that already exists in
|
|
30
|
+
`common/testing/builders.py`.
|
|
31
|
+
|
|
32
|
+
Deliberately NOT flagged:
|
|
33
|
+
|
|
34
|
+
* calls inside a fixture, a `_make_*` helper, or any non-test function — that is
|
|
35
|
+
the factory, and it is allowed to be verbose exactly once,
|
|
36
|
+
* positional arguments — a call with many positionals is a different smell, and
|
|
37
|
+
ruff's own rules already discourage it,
|
|
38
|
+
* `dict(...)` and literal dict displays — those are data, not a domain object,
|
|
39
|
+
and naming their keys is the point rather than the problem.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import ast
|
|
45
|
+
from typing import TYPE_CHECKING, override
|
|
46
|
+
|
|
47
|
+
from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
|
|
48
|
+
from sarj_python_lint.rules._paths import is_test_path
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if TYPE_CHECKING:
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_MAX_KEYWORDS = 8
|
|
56
|
+
|
|
57
|
+
# `dict(a=1, b=2, ...)` is a mapping literal, not a domain object.
|
|
58
|
+
_DATA_CALLABLES = frozenset({"dict"})
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class KwargHeavyConstructionInTest(Rule):
|
|
62
|
+
"""A >8-keyword construction directly in a test body wants a builder."""
|
|
63
|
+
|
|
64
|
+
id: str = "kwarg-heavy-construction-in-test"
|
|
65
|
+
code: str = "SARJ045"
|
|
66
|
+
description: str = "Object built with many keywords inline in a test — extract a builder with defaults."
|
|
67
|
+
|
|
68
|
+
@override
|
|
69
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
70
|
+
"""Flag kwarg-heavy constructions sitting directly in a test body.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
One diagnostic per over-wide construction, sorted by position.
|
|
74
|
+
|
|
75
|
+
"""
|
|
76
|
+
if not is_test_path(path):
|
|
77
|
+
return []
|
|
78
|
+
tree = parse_or_none(path, source)
|
|
79
|
+
if tree is None:
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
visitor = _KwargHeavyVisitor()
|
|
83
|
+
visitor.visit(tree)
|
|
84
|
+
diags = [
|
|
85
|
+
Diagnostic(
|
|
86
|
+
path=path,
|
|
87
|
+
line=node.lineno,
|
|
88
|
+
col=node.col_offset + 1,
|
|
89
|
+
code=self.code,
|
|
90
|
+
message=(
|
|
91
|
+
f"this call passes {count} keywords inline, so the one field under test is buried "
|
|
92
|
+
"and every other test repeats the same boilerplate. Extract a builder with "
|
|
93
|
+
"defaults and override only what this test is about."
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
for node, count in visitor.hits
|
|
97
|
+
]
|
|
98
|
+
diags.sort(key=lambda d: (d.line, d.col))
|
|
99
|
+
return diags
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class _KwargHeavyVisitor(ast.NodeVisitor):
|
|
103
|
+
"""Flag wide keyword calls whose nearest enclosing function is a test.
|
|
104
|
+
|
|
105
|
+
Mirrors SARJ031's enclosing-function stack so a construction inside a
|
|
106
|
+
`_make_*` helper or fixture declared anywhere in the file is attributed to
|
|
107
|
+
that helper — the factory is allowed to be verbose.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self) -> None:
|
|
111
|
+
super().__init__()
|
|
112
|
+
self._func_names: list[str | None] = []
|
|
113
|
+
self.hits: list[tuple[ast.Call, int]] = []
|
|
114
|
+
|
|
115
|
+
def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
116
|
+
self._func_names.append(node.name)
|
|
117
|
+
self.generic_visit(node)
|
|
118
|
+
self._func_names.pop()
|
|
119
|
+
|
|
120
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
121
|
+
self._visit_function(node)
|
|
122
|
+
|
|
123
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
124
|
+
self._visit_function(node)
|
|
125
|
+
|
|
126
|
+
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
127
|
+
self._func_names.append(None)
|
|
128
|
+
self.generic_visit(node)
|
|
129
|
+
self._func_names.pop()
|
|
130
|
+
|
|
131
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
132
|
+
if self._in_test_function() and not _is_data_callable(node.func):
|
|
133
|
+
named = [kw for kw in node.keywords if kw.arg is not None]
|
|
134
|
+
if len(named) > _MAX_KEYWORDS:
|
|
135
|
+
self.hits.append((node, len(named)))
|
|
136
|
+
self.generic_visit(node)
|
|
137
|
+
|
|
138
|
+
def _in_test_function(self) -> bool:
|
|
139
|
+
nearest = self._func_names[-1] if self._func_names else None
|
|
140
|
+
return nearest is not None and nearest.startswith("test_")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _is_data_callable(func: ast.expr) -> bool:
|
|
144
|
+
return isinstance(func, ast.Name) and func.id in _DATA_CALLABLES
|