sarj-python-lint 0.69.1__tar.gz → 0.70.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.69.1 → sarj_python_lint-0.70.0}/PKG-INFO +1 -1
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/pyproject.toml +1 -1
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_registry.py +2 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/duplicate_test_body.py +9 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +2 -2
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +111 -12
- sarj_python_lint-0.70.0/src/sarj_python_lint/rules/repeated_static_call_cases.py +298 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +125 -11
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/.gitignore +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/LICENSE +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/README.md +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_imports.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/source_coupled_test.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
- {sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/unused_mock_setup.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.70.0
|
|
4
4
|
Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
|
|
5
5
|
Project-URL: Homepage, https://code-standards.sarj.ai/rules/python/
|
|
6
6
|
Project-URL: Documentation, https://code-standards.sarj.ai/rules/python/
|
|
@@ -114,6 +114,7 @@ from sarj_python_lint.rules.pydantic_at_boundaries import PydanticAtBoundaries
|
|
|
114
114
|
from sarj_python_lint.rules.redundant_class_docstring import RedundantClassDocstring
|
|
115
115
|
from sarj_python_lint.rules.redundant_docstring import RedundantDocstring
|
|
116
116
|
from sarj_python_lint.rules.redundant_module_docstring import RedundantModuleDocstring
|
|
117
|
+
from sarj_python_lint.rules.repeated_static_call_cases import RepeatedStaticCallCases
|
|
117
118
|
from sarj_python_lint.rules.require_keyword_only_swap_prone_params import (
|
|
118
119
|
RequireKeywordOnlySwapProneParams,
|
|
119
120
|
)
|
|
@@ -218,6 +219,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
|
218
219
|
NegativeOnlyHttpStatusAssertion.id: NegativeOnlyHttpStatusAssertion,
|
|
219
220
|
ProductionDerivedTestCases.id: ProductionDerivedTestCases,
|
|
220
221
|
UncontrolledRandomnessInTest.id: UncontrolledRandomnessInTest,
|
|
222
|
+
RepeatedStaticCallCases.id: RepeatedStaticCallCases,
|
|
221
223
|
}
|
|
222
224
|
)
|
|
223
225
|
|
|
@@ -382,6 +382,15 @@ def _duplicate_groups(tree: ast.Module, source: str) -> list[list[_Shape]]:
|
|
|
382
382
|
return found
|
|
383
383
|
|
|
384
384
|
|
|
385
|
+
def duplicate_test_owner_ids(tree: ast.Module, source: str) -> frozenset[int]:
|
|
386
|
+
"""Return the identities of tests participating in a SARJ066 group."""
|
|
387
|
+
try:
|
|
388
|
+
groups = _duplicate_groups(tree, source)
|
|
389
|
+
except tokenize.TokenError, IndentationError, SyntaxError, RecursionError:
|
|
390
|
+
return frozenset()
|
|
391
|
+
return frozenset(id(member.node) for group in groups for member in group)
|
|
392
|
+
|
|
393
|
+
|
|
385
394
|
def _consecutive_embedded_source_groups(members: list[_Shape], positions: dict[int, int]) -> list[list[_Shape]]:
|
|
386
395
|
"""Keep only long uninterrupted runs of embedded-source checker tests."""
|
|
387
396
|
ordered = sorted(members, key=lambda member: positions[id(member.node)])
|
|
@@ -21,7 +21,7 @@ from sarj_python_lint.rule_base import (
|
|
|
21
21
|
Severity,
|
|
22
22
|
parse_or_none,
|
|
23
23
|
)
|
|
24
|
-
from sarj_python_lint.rules._paths import is_test_path
|
|
24
|
+
from sarj_python_lint.rules._paths import is_generated, is_test_path
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
if TYPE_CHECKING:
|
|
@@ -138,7 +138,7 @@ class NegativeOnlyHttpStatusAssertion(Rule):
|
|
|
138
138
|
|
|
139
139
|
@override
|
|
140
140
|
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
141
|
-
if not is_test_path(path):
|
|
141
|
+
if not is_test_path(path) or is_generated(path, source):
|
|
142
142
|
return []
|
|
143
143
|
tree = parse_or_none(path, source)
|
|
144
144
|
if tree is None:
|
|
@@ -21,7 +21,7 @@ from sarj_python_lint.rule_base import (
|
|
|
21
21
|
Severity,
|
|
22
22
|
parse_or_none,
|
|
23
23
|
)
|
|
24
|
-
from sarj_python_lint.rules._paths import is_test_path
|
|
24
|
+
from sarj_python_lint.rules._paths import is_generated, is_test_path
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
if TYPE_CHECKING:
|
|
@@ -33,7 +33,53 @@ _PARAMETRIZE_CASES_INDEX = 1
|
|
|
33
33
|
_PARAMETRIZE_MIN_ARGS = 2
|
|
34
34
|
|
|
35
35
|
|
|
36
|
-
def
|
|
36
|
+
def _scope_binding_counts(statements: list[ast.stmt]) -> dict[str, int]:
|
|
37
|
+
"""Count bindings in one scope without attributing nested scopes to it."""
|
|
38
|
+
counts: dict[str, int] = {}
|
|
39
|
+
stack: list[ast.AST] = [*reversed(statements)]
|
|
40
|
+
while stack:
|
|
41
|
+
node = stack.pop()
|
|
42
|
+
names: set[str] = set()
|
|
43
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
44
|
+
names.add(node.name)
|
|
45
|
+
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
46
|
+
names.update(alias.asname or alias.name.split(".")[0] for alias in node.names)
|
|
47
|
+
elif isinstance(node, (ast.Assign, ast.Delete)):
|
|
48
|
+
names.update(*(_bound_target_names(target) for target in node.targets))
|
|
49
|
+
elif isinstance(node, (ast.AnnAssign, ast.AugAssign, ast.NamedExpr, ast.For, ast.AsyncFor)):
|
|
50
|
+
names.update(_bound_target_names(node.target))
|
|
51
|
+
elif isinstance(node, (ast.ExceptHandler, ast.MatchAs, ast.MatchStar)) and node.name is not None:
|
|
52
|
+
names.add(node.name)
|
|
53
|
+
elif isinstance(node, ast.MatchMapping) and node.rest is not None:
|
|
54
|
+
names.add(node.rest)
|
|
55
|
+
elif isinstance(node, (ast.With, ast.AsyncWith)):
|
|
56
|
+
names.update(
|
|
57
|
+
*(_bound_target_names(item.optional_vars) for item in node.items if item.optional_vars is not None)
|
|
58
|
+
)
|
|
59
|
+
for name in names:
|
|
60
|
+
counts[name] = counts.get(name, 0) + 1
|
|
61
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
|
|
62
|
+
continue
|
|
63
|
+
stack.extend(reversed(list(ast.iter_child_nodes(node))))
|
|
64
|
+
return counts
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _bound_target_names(node: ast.AST) -> set[str]:
|
|
68
|
+
match node:
|
|
69
|
+
case ast.Name(id=name):
|
|
70
|
+
return {name}
|
|
71
|
+
case ast.Tuple() | ast.List():
|
|
72
|
+
names: set[str] = set()
|
|
73
|
+
for element in node.elts:
|
|
74
|
+
names.update(_bound_target_names(element))
|
|
75
|
+
return names
|
|
76
|
+
case ast.Starred(value=value):
|
|
77
|
+
return _bound_target_names(value)
|
|
78
|
+
case _:
|
|
79
|
+
return set()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _imported_bindings(tree: ast.Module, binding_counts: dict[str, int]) -> set[str]:
|
|
37
83
|
bindings: set[str] = set()
|
|
38
84
|
for node in tree.body:
|
|
39
85
|
if (
|
|
@@ -41,10 +87,33 @@ def _imported_bindings(tree: ast.Module) -> set[str]:
|
|
|
41
87
|
and node.module is not None
|
|
42
88
|
and not node.module.startswith(("test", "tests"))
|
|
43
89
|
):
|
|
44
|
-
bindings.update(
|
|
90
|
+
bindings.update(
|
|
91
|
+
local
|
|
92
|
+
for alias in node.names
|
|
93
|
+
if alias.name != "*" and binding_counts.get(local := alias.asname or alias.name, 0) == 1
|
|
94
|
+
)
|
|
45
95
|
return bindings
|
|
46
96
|
|
|
47
97
|
|
|
98
|
+
def _pytest_bindings(tree: ast.Module, binding_counts: dict[str, int]) -> tuple[set[str], set[str]]:
|
|
99
|
+
modules: set[str] = set()
|
|
100
|
+
marks: set[str] = set()
|
|
101
|
+
for node in tree.body:
|
|
102
|
+
if isinstance(node, ast.Import):
|
|
103
|
+
modules.update(
|
|
104
|
+
local
|
|
105
|
+
for alias in node.names
|
|
106
|
+
if alias.name == "pytest" and binding_counts.get(local := alias.asname or alias.name, 0) == 1
|
|
107
|
+
)
|
|
108
|
+
elif isinstance(node, ast.ImportFrom) and node.module == "pytest":
|
|
109
|
+
marks.update(
|
|
110
|
+
local
|
|
111
|
+
for alias in node.names
|
|
112
|
+
if alias.name == "mark" and binding_counts.get(local := alias.asname or alias.name, 0) == 1
|
|
113
|
+
)
|
|
114
|
+
return modules, marks
|
|
115
|
+
|
|
116
|
+
|
|
48
117
|
def _direct_imported_collection(node: ast.expr, imported: set[str]) -> ast.Name | None:
|
|
49
118
|
if isinstance(node, ast.Name) and node.id in imported and node.id.isupper() and not _is_registry(node.id):
|
|
50
119
|
return node
|
|
@@ -79,15 +148,45 @@ def _is_imported_collection_wrapper(node: ast.Call, imported: set[str]) -> bool:
|
|
|
79
148
|
)
|
|
80
149
|
|
|
81
150
|
|
|
82
|
-
def _parametrize_cases(
|
|
151
|
+
def _parametrize_cases(
|
|
152
|
+
decorator: ast.expr,
|
|
153
|
+
pytest_modules: set[str],
|
|
154
|
+
pytest_marks: set[str],
|
|
155
|
+
blocked: set[str],
|
|
156
|
+
) -> ast.expr | None:
|
|
83
157
|
if not isinstance(decorator, ast.Call) or len(decorator.args) < _PARAMETRIZE_MIN_ARGS:
|
|
84
158
|
return None
|
|
85
159
|
func = decorator.func
|
|
86
160
|
if not isinstance(func, ast.Attribute) or func.attr != "parametrize":
|
|
87
161
|
return None
|
|
162
|
+
owner = func.value
|
|
163
|
+
module_owned = (
|
|
164
|
+
isinstance(owner, ast.Attribute)
|
|
165
|
+
and owner.attr == "mark"
|
|
166
|
+
and isinstance(owner.value, ast.Name)
|
|
167
|
+
and owner.value.id in pytest_modules - blocked
|
|
168
|
+
)
|
|
169
|
+
mark_owned = isinstance(owner, ast.Name) and owner.id in pytest_marks - blocked
|
|
170
|
+
if not module_owned and not mark_owned:
|
|
171
|
+
return None
|
|
88
172
|
return decorator.args[_PARAMETRIZE_CASES_INDEX]
|
|
89
173
|
|
|
90
174
|
|
|
175
|
+
def _collected_tests(tree: ast.Module) -> list[tuple[ast.FunctionDef | ast.AsyncFunctionDef, set[str]]]:
|
|
176
|
+
tests: list[tuple[ast.FunctionDef | ast.AsyncFunctionDef, set[str]]] = []
|
|
177
|
+
for node in tree.body:
|
|
178
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test_"):
|
|
179
|
+
tests.append((node, set()))
|
|
180
|
+
elif isinstance(node, ast.ClassDef) and node.name.startswith("Test"):
|
|
181
|
+
class_bindings = set(_scope_binding_counts(node.body))
|
|
182
|
+
tests.extend(
|
|
183
|
+
(child, class_bindings)
|
|
184
|
+
for child in node.body
|
|
185
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child.name.startswith("test_")
|
|
186
|
+
)
|
|
187
|
+
return tests
|
|
188
|
+
|
|
189
|
+
|
|
91
190
|
def _independently_asserted_collections(tree: ast.Module, imported: set[str]) -> set[str]:
|
|
92
191
|
asserted: set[str] = set()
|
|
93
192
|
for node in ast.walk(tree):
|
|
@@ -128,7 +227,7 @@ class ProductionDerivedTestCases(Rule):
|
|
|
128
227
|
files=(
|
|
129
228
|
ExampleFile.python(
|
|
130
229
|
"tests/test_models.py",
|
|
131
|
-
"
|
|
230
|
+
"import pytest\n\nEXPECTED_MODELS = ('a', 'b')\n\n@pytest.mark.parametrize('model', EXPECTED_MODELS)\ndef test_model(model):\n assert build(model).tier == 'priority'\n",
|
|
132
231
|
),
|
|
133
232
|
),
|
|
134
233
|
focus_path=PurePosixPath("tests/test_models.py"),
|
|
@@ -142,7 +241,7 @@ class ProductionDerivedTestCases(Rule):
|
|
|
142
241
|
files=(
|
|
143
242
|
ExampleFile.python(
|
|
144
243
|
"tests/test_models.py",
|
|
145
|
-
"
|
|
244
|
+
"import pytest\nfrom app.models import ELIGIBLE_MODELS\n\n@pytest.mark.parametrize('model', ELIGIBLE_MODELS)\ndef test_model(model):\n assert build(model).tier == 'priority'\n",
|
|
146
245
|
),
|
|
147
246
|
),
|
|
148
247
|
focus_path=PurePosixPath("tests/test_models.py"),
|
|
@@ -155,19 +254,19 @@ class ProductionDerivedTestCases(Rule):
|
|
|
155
254
|
|
|
156
255
|
@override
|
|
157
256
|
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
158
|
-
if not is_test_path(path):
|
|
257
|
+
if not is_test_path(path) or is_generated(path, source):
|
|
159
258
|
return []
|
|
160
259
|
tree = parse_or_none(path, source)
|
|
161
260
|
if tree is None:
|
|
162
261
|
return []
|
|
163
|
-
|
|
262
|
+
binding_counts = _scope_binding_counts(tree.body)
|
|
263
|
+
imported = _imported_bindings(tree, binding_counts)
|
|
264
|
+
pytest_modules, pytest_marks = _pytest_bindings(tree, binding_counts)
|
|
164
265
|
independently_asserted = _independently_asserted_collections(tree, imported)
|
|
165
266
|
findings: list[Diagnostic] = []
|
|
166
|
-
for node in
|
|
167
|
-
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) or not node.name.startswith("test_"):
|
|
168
|
-
continue
|
|
267
|
+
for node, blocked in _collected_tests(tree):
|
|
169
268
|
for decorator in node.decorator_list:
|
|
170
|
-
cases = _parametrize_cases(decorator)
|
|
269
|
+
cases = _parametrize_cases(decorator, pytest_modules, pytest_marks, blocked)
|
|
171
270
|
if cases is None:
|
|
172
271
|
continue
|
|
173
272
|
collection = _direct_imported_collection(cases, imported)
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""SARJ413 — Repeated static call assertions should be named test cases.
|
|
2
|
+
|
|
3
|
+
Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_repeated_static_call_cases.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
from io import StringIO
|
|
10
|
+
from pathlib import PurePosixPath
|
|
11
|
+
import tokenize
|
|
12
|
+
from typing import TYPE_CHECKING, final, override
|
|
13
|
+
|
|
14
|
+
from sarj_python_lint.rule_base import (
|
|
15
|
+
AutofixPolicy,
|
|
16
|
+
Diagnostic,
|
|
17
|
+
ExampleFile,
|
|
18
|
+
ExampleOutcome,
|
|
19
|
+
Rule,
|
|
20
|
+
RuleCategory,
|
|
21
|
+
RuleDocumentation,
|
|
22
|
+
RuleExample,
|
|
23
|
+
Severity,
|
|
24
|
+
parse_or_none,
|
|
25
|
+
)
|
|
26
|
+
from sarj_python_lint.rules._paths import is_generated, is_test_path
|
|
27
|
+
from sarj_python_lint.rules.duplicate_test_body import duplicate_test_owner_ids
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from collections.abc import Iterator
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_MIN_CASES = 3
|
|
36
|
+
_MIN_DISTINCT_CASES = 2
|
|
37
|
+
_FUNC_NODES = (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
38
|
+
_ACCESSOR_CALLEES = frozenset({"get"})
|
|
39
|
+
_UNSAFE_CALLEE_PARTS = frozenset({"mock", "snapshot", "spy"})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@final
|
|
43
|
+
class RepeatedStaticCallCases(Rule):
|
|
44
|
+
id = "repeated-static-call-cases"
|
|
45
|
+
code = "SARJ413"
|
|
46
|
+
documentation = RuleDocumentation(
|
|
47
|
+
summary="Repeated static call assertions are hidden inside one coarse test case.",
|
|
48
|
+
rationale=(
|
|
49
|
+
"When several independent static inputs share one test callback, the first failure hides later cases "
|
|
50
|
+
"and the runner cannot name the input that failed."
|
|
51
|
+
),
|
|
52
|
+
remediation="Move the inputs and expectations into a named pytest parameter table.",
|
|
53
|
+
category=RuleCategory.TESTING,
|
|
54
|
+
autofix=AutofixPolicy.NONE,
|
|
55
|
+
limitations=(
|
|
56
|
+
"Only runs of at least three consecutive top-level assertions in collected pytest-style tests are checked.",
|
|
57
|
+
"Calls, inputs, and expectations must be statically representable; unittest classes, zero-argument calls, mocks, snapshots, and intervening prose or setup are excluded.",
|
|
58
|
+
"Common mapping accessors are excluded because repeated field assertions usually describe one cohesive object contract, not independent input cases.",
|
|
59
|
+
"Tests participating in a duplicate-test-body group are left to SARJ066, which has the broader finding.",
|
|
60
|
+
"Case names and parameter boundaries require judgment, so the rule has no autofix.",
|
|
61
|
+
),
|
|
62
|
+
examples=(
|
|
63
|
+
RuleExample(
|
|
64
|
+
example_id="parameterized-parser-cases",
|
|
65
|
+
title="Give each parser input a runner-visible case",
|
|
66
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
67
|
+
files=(
|
|
68
|
+
ExampleFile.python(
|
|
69
|
+
"tests/test_parser.py",
|
|
70
|
+
"@pytest.mark.parametrize(('value', 'expected'), [('a', 1), ('b', 2), ('c', 3)])\ndef test_parse(value, expected):\n assert parse(value) == expected\n",
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
focus_path=PurePosixPath("tests/test_parser.py"),
|
|
74
|
+
expected_count=0,
|
|
75
|
+
public=True,
|
|
76
|
+
),
|
|
77
|
+
RuleExample(
|
|
78
|
+
example_id="repeated-parser-assertions",
|
|
79
|
+
title="Do not hide independent inputs in one callback",
|
|
80
|
+
outcome=ExampleOutcome.MATCH,
|
|
81
|
+
files=(
|
|
82
|
+
ExampleFile.python(
|
|
83
|
+
"tests/test_parser.py",
|
|
84
|
+
"def test_parse():\n assert parse('a') == 1\n assert parse('b') == 2\n assert parse('c') == 3\n",
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
focus_path=PurePosixPath("tests/test_parser.py"),
|
|
88
|
+
expected_count=1,
|
|
89
|
+
public=True,
|
|
90
|
+
),
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
description = documentation.summary
|
|
94
|
+
|
|
95
|
+
@override
|
|
96
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
97
|
+
if not is_test_path(path) or is_generated(path, source):
|
|
98
|
+
return []
|
|
99
|
+
tree = parse_or_none(path, source)
|
|
100
|
+
if tree is None:
|
|
101
|
+
return []
|
|
102
|
+
comments = _comment_lines(source)
|
|
103
|
+
duplicate_owners = duplicate_test_owner_ids(tree, source)
|
|
104
|
+
findings = [
|
|
105
|
+
Diagnostic(
|
|
106
|
+
path=path,
|
|
107
|
+
line=run[0].lineno,
|
|
108
|
+
col=run[0].col_offset + 1,
|
|
109
|
+
code=self.code,
|
|
110
|
+
severity=Severity.WARNING,
|
|
111
|
+
message=(
|
|
112
|
+
f"these {len(run)} static call assertions run as one coarse test; move the inputs and "
|
|
113
|
+
"expectations into named pytest parameters."
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
for test in _test_functions(tree)
|
|
117
|
+
if id(test) not in duplicate_owners
|
|
118
|
+
for run in _runs(test, comments)
|
|
119
|
+
]
|
|
120
|
+
return sorted(findings, key=lambda finding: (finding.line, finding.col))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _dotted_name(node: ast.expr) -> tuple[str, ...] | None:
|
|
124
|
+
if isinstance(node, ast.Name):
|
|
125
|
+
return (node.id,)
|
|
126
|
+
if isinstance(node, ast.Attribute):
|
|
127
|
+
parent = _dotted_name(node.value)
|
|
128
|
+
return None if parent is None else (*parent, node.attr)
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _comment_lines(source: str) -> frozenset[int]:
|
|
133
|
+
try:
|
|
134
|
+
return frozenset(
|
|
135
|
+
token.start[0]
|
|
136
|
+
for token in tokenize.generate_tokens(StringIO(source).readline)
|
|
137
|
+
if token.type == tokenize.COMMENT
|
|
138
|
+
)
|
|
139
|
+
except IndentationError, tokenize.TokenError:
|
|
140
|
+
return frozenset()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _test_functions(tree: ast.Module) -> Iterator[ast.FunctionDef | ast.AsyncFunctionDef]:
|
|
144
|
+
local_classes = {node.name: node for node in tree.body if isinstance(node, ast.ClassDef)}
|
|
145
|
+
|
|
146
|
+
def is_test_case(node: ast.ClassDef, seen: frozenset[str] = frozenset()) -> bool:
|
|
147
|
+
if node.name in seen:
|
|
148
|
+
return False
|
|
149
|
+
for base in node.bases:
|
|
150
|
+
name = _base_name(base)
|
|
151
|
+
if name is not None and name.endswith("TestCase"):
|
|
152
|
+
return True
|
|
153
|
+
if name in local_classes and is_test_case(local_classes[name], seen | {node.name}):
|
|
154
|
+
return True
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
for statement in tree.body:
|
|
158
|
+
if isinstance(statement, _FUNC_NODES) and statement.name.startswith("test_"):
|
|
159
|
+
yield statement
|
|
160
|
+
elif isinstance(statement, ast.ClassDef) and not is_test_case(statement):
|
|
161
|
+
yield from (
|
|
162
|
+
child for child in statement.body if isinstance(child, _FUNC_NODES) and child.name.startswith("test_")
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _base_name(node: ast.expr) -> str | None:
|
|
167
|
+
dotted = _dotted_name(node)
|
|
168
|
+
return None if dotted is None else dotted[-1]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _runs(test: ast.FunctionDef | ast.AsyncFunctionDef, comments: frozenset[int]) -> Iterator[list[ast.Assert]]:
|
|
172
|
+
current: list[ast.Assert] = []
|
|
173
|
+
current_shape: object | None = None
|
|
174
|
+
current_values: set[str] = set()
|
|
175
|
+
for statement in test.body:
|
|
176
|
+
if not isinstance(statement, ast.Assert) or _has_attached_comment(statement, comments):
|
|
177
|
+
if len(current) >= _MIN_CASES and len(current_values) >= _MIN_DISTINCT_CASES:
|
|
178
|
+
yield current
|
|
179
|
+
current, current_shape, current_values = [], None, set()
|
|
180
|
+
continue
|
|
181
|
+
parsed = _assertion_shape(statement)
|
|
182
|
+
if parsed is None:
|
|
183
|
+
if len(current) >= _MIN_CASES and len(current_values) >= _MIN_DISTINCT_CASES:
|
|
184
|
+
yield current
|
|
185
|
+
current, current_shape, current_values = [], None, set()
|
|
186
|
+
continue
|
|
187
|
+
shape, values = parsed
|
|
188
|
+
if current and (shape != current_shape or _has_intervening_comment(current[-1], statement, comments)):
|
|
189
|
+
if len(current) >= _MIN_CASES and len(current_values) >= _MIN_DISTINCT_CASES:
|
|
190
|
+
yield current
|
|
191
|
+
current, current_values = [], set()
|
|
192
|
+
current.append(statement)
|
|
193
|
+
current_shape = shape
|
|
194
|
+
current_values.add(values)
|
|
195
|
+
if len(current) >= _MIN_CASES and len(current_values) >= _MIN_DISTINCT_CASES:
|
|
196
|
+
yield current
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _assertion_shape(node: ast.Assert) -> tuple[object, str] | None:
|
|
200
|
+
expression = node.test
|
|
201
|
+
polarity = "truthy"
|
|
202
|
+
expectation: ast.expr | None = None
|
|
203
|
+
call_expr: ast.expr
|
|
204
|
+
if isinstance(expression, ast.UnaryOp) and isinstance(expression.op, ast.Not):
|
|
205
|
+
polarity = "falsy"
|
|
206
|
+
call_expr = expression.operand
|
|
207
|
+
elif isinstance(expression, ast.Compare) and len(expression.ops) == 1 and len(expression.comparators) == 1:
|
|
208
|
+
call_expr = expression.left
|
|
209
|
+
expectation = expression.comparators[0]
|
|
210
|
+
polarity = type(expression.ops[0]).__name__
|
|
211
|
+
else:
|
|
212
|
+
call_expr = expression
|
|
213
|
+
parsed = _call(call_expr)
|
|
214
|
+
if parsed is None or (expectation is not None and not _static(expectation)):
|
|
215
|
+
return None
|
|
216
|
+
call, awaited = parsed
|
|
217
|
+
callee = _dotted_name(call.func)
|
|
218
|
+
if (
|
|
219
|
+
callee is None
|
|
220
|
+
or not _eligible_callee(callee)
|
|
221
|
+
or not call.args
|
|
222
|
+
or any(isinstance(arg, ast.Starred) or not _static(arg) for arg in call.args)
|
|
223
|
+
or any(keyword.arg is None or not _static(keyword.value) for keyword in call.keywords)
|
|
224
|
+
):
|
|
225
|
+
return None
|
|
226
|
+
skeleton = (
|
|
227
|
+
callee,
|
|
228
|
+
awaited,
|
|
229
|
+
polarity,
|
|
230
|
+
tuple(_static_shape(arg) for arg in call.args),
|
|
231
|
+
tuple((keyword.arg, _static_shape(keyword.value)) for keyword in call.keywords),
|
|
232
|
+
None if expectation is None else _static_shape(expectation),
|
|
233
|
+
)
|
|
234
|
+
values = _static_value(ast.Tuple(elts=[*call.args, *(keyword.value for keyword in call.keywords)], ctx=ast.Load()))
|
|
235
|
+
return skeleton, values
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _eligible_callee(callee: tuple[str, ...]) -> bool:
|
|
239
|
+
return callee[-1] not in _ACCESSOR_CALLEES and not any(
|
|
240
|
+
any(part in segment.lower() for part in _UNSAFE_CALLEE_PARTS) for segment in callee
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _has_intervening_comment(previous: ast.Assert, current: ast.Assert, comments: frozenset[int]) -> bool:
|
|
245
|
+
return any((previous.end_lineno or previous.lineno) < line < current.lineno for line in comments)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _has_attached_comment(statement: ast.Assert, comments: frozenset[int]) -> bool:
|
|
249
|
+
return any(statement.lineno <= line <= (statement.end_lineno or statement.lineno) for line in comments)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _call(node: ast.expr) -> tuple[ast.Call, bool] | None:
|
|
253
|
+
awaited = isinstance(node, ast.Await)
|
|
254
|
+
candidate = node.value if awaited else node
|
|
255
|
+
return (candidate, awaited) if isinstance(candidate, ast.Call) else None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _static(node: ast.expr) -> bool:
|
|
259
|
+
match node:
|
|
260
|
+
case ast.Constant():
|
|
261
|
+
return True
|
|
262
|
+
case ast.Attribute():
|
|
263
|
+
return _dotted_name(node) is not None
|
|
264
|
+
case ast.UnaryOp(op=ast.UAdd() | ast.USub() | ast.Invert(), operand=operand):
|
|
265
|
+
return _static(operand)
|
|
266
|
+
case ast.Tuple() | ast.List() | ast.Set():
|
|
267
|
+
return all(_static(elt) for elt in node.elts)
|
|
268
|
+
case ast.Dict(keys=keys, values=values):
|
|
269
|
+
return all(key is not None and _static(key) for key in keys) and all(_static(value) for value in values)
|
|
270
|
+
case _:
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _static_shape(node: ast.expr) -> object:
|
|
275
|
+
match node:
|
|
276
|
+
case ast.Constant(value=value):
|
|
277
|
+
return ("constant", type(value).__name__)
|
|
278
|
+
case ast.Attribute():
|
|
279
|
+
return ("symbol", len(_dotted_name(node) or ()))
|
|
280
|
+
case ast.UnaryOp(op=op, operand=operand):
|
|
281
|
+
return (type(op).__name__, _static_shape(operand))
|
|
282
|
+
case ast.Tuple() | ast.List() | ast.Set():
|
|
283
|
+
return (type(node).__name__, tuple(_static_shape(elt) for elt in node.elts))
|
|
284
|
+
case ast.Dict(keys=keys, values=values):
|
|
285
|
+
return (
|
|
286
|
+
"Dict",
|
|
287
|
+
tuple(
|
|
288
|
+
(_static_shape(key), _static_shape(value)) for key, value in zip(keys, values, strict=True) if key
|
|
289
|
+
),
|
|
290
|
+
)
|
|
291
|
+
case _:
|
|
292
|
+
raise AssertionError
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _static_value(node: ast.expr) -> str:
|
|
296
|
+
"""Return a bounded comparison token without retaining a large literal."""
|
|
297
|
+
rendered = ast.dump(node, annotate_fields=False, include_attributes=False)
|
|
298
|
+
return rendered[:512]
|
|
@@ -21,7 +21,7 @@ from sarj_python_lint.rule_base import (
|
|
|
21
21
|
Severity,
|
|
22
22
|
parse_or_none,
|
|
23
23
|
)
|
|
24
|
-
from sarj_python_lint.rules._paths import is_test_path
|
|
24
|
+
from sarj_python_lint.rules._paths import is_generated, is_test_path
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
if TYPE_CHECKING:
|
|
@@ -33,16 +33,78 @@ _PRNG_FUNCTIONS = frozenset({"choice", "choices", "randint", "random", "randrang
|
|
|
33
33
|
_REPEAT_NODES = (ast.For, ast.AsyncFor, ast.While, ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
|
|
34
34
|
|
|
35
35
|
|
|
36
|
+
def _bound_target_names(node: ast.AST) -> set[str]:
|
|
37
|
+
match node:
|
|
38
|
+
case ast.Name(id=name):
|
|
39
|
+
return {name}
|
|
40
|
+
case ast.Tuple() | ast.List():
|
|
41
|
+
names: set[str] = set()
|
|
42
|
+
for element in node.elts:
|
|
43
|
+
names.update(_bound_target_names(element))
|
|
44
|
+
return names
|
|
45
|
+
case ast.Starred(value=value):
|
|
46
|
+
return _bound_target_names(value)
|
|
47
|
+
case _:
|
|
48
|
+
return set()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _scope_binding_events(statements: list[ast.stmt]) -> list[str]:
|
|
52
|
+
"""Return binding events in one scope without entering nested scopes."""
|
|
53
|
+
bindings: list[str] = []
|
|
54
|
+
stack: list[ast.AST] = [*reversed(statements)]
|
|
55
|
+
while stack:
|
|
56
|
+
node = stack.pop()
|
|
57
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
58
|
+
bindings.append(node.name)
|
|
59
|
+
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
60
|
+
bindings.extend(alias.asname or alias.name.split(".")[0] for alias in node.names)
|
|
61
|
+
elif isinstance(node, (ast.Assign, ast.Delete)):
|
|
62
|
+
for target in node.targets:
|
|
63
|
+
bindings.extend(_bound_target_names(target))
|
|
64
|
+
elif isinstance(node, (ast.AnnAssign, ast.AugAssign, ast.NamedExpr, ast.For, ast.AsyncFor)):
|
|
65
|
+
bindings.extend(_bound_target_names(node.target))
|
|
66
|
+
elif isinstance(node, (ast.ExceptHandler, ast.MatchAs, ast.MatchStar)) and node.name is not None:
|
|
67
|
+
bindings.append(node.name)
|
|
68
|
+
elif isinstance(node, ast.MatchMapping) and node.rest is not None:
|
|
69
|
+
bindings.append(node.rest)
|
|
70
|
+
elif isinstance(node, (ast.With, ast.AsyncWith)):
|
|
71
|
+
for item in node.items:
|
|
72
|
+
if item.optional_vars is not None:
|
|
73
|
+
bindings.extend(_bound_target_names(item.optional_vars))
|
|
74
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
|
|
75
|
+
continue
|
|
76
|
+
stack.extend(reversed(list(ast.iter_child_nodes(node))))
|
|
77
|
+
return bindings
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _scope_bindings(statements: list[ast.stmt]) -> set[str]:
|
|
81
|
+
return set(_scope_binding_events(statements))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _module_binding_counts(tree: ast.Module) -> dict[str, int]:
|
|
85
|
+
counts: dict[str, int] = {}
|
|
86
|
+
for name in _scope_binding_events(tree.body):
|
|
87
|
+
counts[name] = counts.get(name, 0) + 1
|
|
88
|
+
return counts
|
|
89
|
+
|
|
90
|
+
|
|
36
91
|
def _random_aliases(tree: ast.Module) -> tuple[set[str], set[str], set[str]]:
|
|
37
92
|
modules: set[str] = set()
|
|
38
93
|
functions: set[str] = set()
|
|
39
94
|
seeds: set[str] = set()
|
|
95
|
+
binding_counts = _module_binding_counts(tree)
|
|
40
96
|
for node in tree.body:
|
|
41
97
|
if isinstance(node, ast.Import):
|
|
42
|
-
modules.update(
|
|
98
|
+
modules.update(
|
|
99
|
+
local
|
|
100
|
+
for alias in node.names
|
|
101
|
+
if alias.name == "random" and binding_counts.get(local := alias.asname or alias.name, 0) == 1
|
|
102
|
+
)
|
|
43
103
|
elif isinstance(node, ast.ImportFrom) and node.module == "random":
|
|
44
104
|
for alias in node.names:
|
|
45
105
|
local = alias.asname or alias.name
|
|
106
|
+
if binding_counts.get(local, 0) != 1:
|
|
107
|
+
continue
|
|
46
108
|
if alias.name in _PRNG_FUNCTIONS:
|
|
47
109
|
functions.add(local)
|
|
48
110
|
elif alias.name == "seed":
|
|
@@ -78,11 +140,38 @@ def _test_nodes(test: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[ast.AS
|
|
|
78
140
|
while stack:
|
|
79
141
|
node = stack.pop()
|
|
80
142
|
yield node
|
|
81
|
-
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
|
143
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
|
|
82
144
|
continue
|
|
83
145
|
stack.extend(reversed(list(ast.iter_child_nodes(node))))
|
|
84
146
|
|
|
85
147
|
|
|
148
|
+
def _bounded_nodes(root: ast.AST) -> Iterator[ast.AST]:
|
|
149
|
+
"""Walk a subtree without entering nested callable or class scopes."""
|
|
150
|
+
stack = [root]
|
|
151
|
+
while stack:
|
|
152
|
+
node = stack.pop()
|
|
153
|
+
yield node
|
|
154
|
+
if node is not root and isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
|
|
155
|
+
continue
|
|
156
|
+
stack.extend(reversed(list(ast.iter_child_nodes(node))))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _test_nodes_for_statement(statement: ast.stmt) -> Iterator[ast.AST]:
|
|
160
|
+
yield from _bounded_nodes(statement)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _collected_tests(tree: ast.Module) -> Iterator[ast.FunctionDef | ast.AsyncFunctionDef]:
|
|
164
|
+
for node in tree.body:
|
|
165
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test_"):
|
|
166
|
+
yield node
|
|
167
|
+
elif isinstance(node, ast.ClassDef) and node.name.startswith("Test"):
|
|
168
|
+
yield from (
|
|
169
|
+
child
|
|
170
|
+
for child in node.body
|
|
171
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child.name.startswith("test_")
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
86
175
|
@final
|
|
87
176
|
class UncontrolledRandomnessInTest(Rule):
|
|
88
177
|
id = "uncontrolled-randomness-in-test"
|
|
@@ -99,7 +188,7 @@ class UncontrolledRandomnessInTest(Rule):
|
|
|
99
188
|
limitations=(
|
|
100
189
|
"Only standard-library `random` calls nested in loops or comprehensions in collected tests are checked.",
|
|
101
190
|
"Single draws, injected RNG objects, Hypothesis tests, and cryptographic randomness are excluded.",
|
|
102
|
-
"
|
|
191
|
+
"Only an unconditional top-level seed before the repeated sample suppresses the finding; interprocedural seeding is not inferred.",
|
|
103
192
|
),
|
|
104
193
|
examples=(
|
|
105
194
|
RuleExample(
|
|
@@ -136,7 +225,7 @@ class UncontrolledRandomnessInTest(Rule):
|
|
|
136
225
|
|
|
137
226
|
@override
|
|
138
227
|
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
139
|
-
if not is_test_path(path):
|
|
228
|
+
if not is_test_path(path) or is_generated(path, source):
|
|
140
229
|
return []
|
|
141
230
|
tree = parse_or_none(path, source)
|
|
142
231
|
if tree is None:
|
|
@@ -145,17 +234,42 @@ class UncontrolledRandomnessInTest(Rule):
|
|
|
145
234
|
if not modules and not functions:
|
|
146
235
|
return []
|
|
147
236
|
findings: list[Diagnostic] = []
|
|
148
|
-
for test in
|
|
149
|
-
|
|
237
|
+
for test in _collected_tests(tree):
|
|
238
|
+
local_bindings = _scope_bindings(test.body) | {
|
|
239
|
+
arg.arg for arg in (*test.args.posonlyargs, *test.args.args, *test.args.kwonlyargs)
|
|
240
|
+
}
|
|
241
|
+
if test.args.vararg is not None:
|
|
242
|
+
local_bindings.add(test.args.vararg.arg)
|
|
243
|
+
if test.args.kwarg is not None:
|
|
244
|
+
local_bindings.add(test.args.kwarg.arg)
|
|
245
|
+
test_modules = modules - local_bindings
|
|
246
|
+
test_functions = functions - local_bindings
|
|
247
|
+
test_seeds = seeds - local_bindings
|
|
248
|
+
if not test_modules and not test_functions:
|
|
150
249
|
continue
|
|
151
250
|
nodes = list(_test_nodes(test))
|
|
152
|
-
|
|
153
|
-
|
|
251
|
+
top_level_seed_indexes = {
|
|
252
|
+
index
|
|
253
|
+
for index, statement in enumerate(test.body)
|
|
254
|
+
if isinstance(statement, ast.Expr)
|
|
255
|
+
and isinstance(statement.value, ast.Call)
|
|
256
|
+
and _is_seed_call(statement.value, test_modules, test_seeds)
|
|
257
|
+
}
|
|
154
258
|
for repeated in (node for node in nodes if isinstance(node, _REPEAT_NODES)):
|
|
259
|
+
owner_index = next(
|
|
260
|
+
(
|
|
261
|
+
index
|
|
262
|
+
for index, statement in enumerate(test.body)
|
|
263
|
+
if repeated is statement or repeated in _test_nodes_for_statement(statement)
|
|
264
|
+
),
|
|
265
|
+
None,
|
|
266
|
+
)
|
|
267
|
+
if owner_index is not None and any(seed_index < owner_index for seed_index in top_level_seed_indexes):
|
|
268
|
+
continue
|
|
155
269
|
calls = [
|
|
156
270
|
node
|
|
157
|
-
for node in
|
|
158
|
-
if isinstance(node, ast.Call) and _is_prng_call(node,
|
|
271
|
+
for node in _bounded_nodes(repeated)
|
|
272
|
+
if isinstance(node, ast.Call) and _is_prng_call(node, test_modules, test_functions)
|
|
159
273
|
]
|
|
160
274
|
findings.extend(
|
|
161
275
|
Diagnostic(
|
|
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
|
|
File without changes
|
{sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_ast_index.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_docstrings.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_first_party.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_prose_budget.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_test_assertions.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
|
{sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/mock_without_spec.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_comment_cruft.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
|
{sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_long_comment.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_secret_in_log.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_select_star.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_stdlib_logging.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/over_mocked_test.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.69.1 → sarj_python_lint-0.70.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_or_pattern.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.69.1 → sarj_python_lint-0.70.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
|
|
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.69.1 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/unused_mock_setup.py
RENAMED
|
File without changes
|