sarj-python-lint 0.80.12__tar.gz → 0.80.13__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.80.12 → sarj_python_lint-0.80.13}/PKG-INFO +1 -1
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/pyproject.toml +1 -1
- sarj_python_lint-0.80.13/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +480 -0
- sarj_python_lint-0.80.12/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -290
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/.gitignore +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/LICENSE +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/README.md +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/_analysis_session.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_fastapi.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_imports.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_project_index.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_registry.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
- {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sarj-python-lint
|
|
3
|
-
Version: 0.80.
|
|
3
|
+
Version: 0.80.13
|
|
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/
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import PurePosixPath
|
|
6
|
+
from typing import TYPE_CHECKING, final, override
|
|
7
|
+
|
|
8
|
+
from sarj_python_lint.rule_base import (
|
|
9
|
+
AutofixPolicy,
|
|
10
|
+
Diagnostic,
|
|
11
|
+
ExampleFile,
|
|
12
|
+
ExampleOutcome,
|
|
13
|
+
Rule,
|
|
14
|
+
RuleCategory,
|
|
15
|
+
RuleDocumentation,
|
|
16
|
+
RuleExample,
|
|
17
|
+
Severity,
|
|
18
|
+
parse_or_none,
|
|
19
|
+
)
|
|
20
|
+
from sarj_python_lint.rules._paths import is_generated, is_test_path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Iterator
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_PRNG_FUNCTIONS = frozenset(
|
|
29
|
+
{
|
|
30
|
+
"betavariate",
|
|
31
|
+
"binomialvariate",
|
|
32
|
+
"choice",
|
|
33
|
+
"choices",
|
|
34
|
+
"expovariate",
|
|
35
|
+
"gammavariate",
|
|
36
|
+
"gauss",
|
|
37
|
+
"getrandbits",
|
|
38
|
+
"lognormvariate",
|
|
39
|
+
"normalvariate",
|
|
40
|
+
"paretovariate",
|
|
41
|
+
"randbytes",
|
|
42
|
+
"randint",
|
|
43
|
+
"random",
|
|
44
|
+
"randrange",
|
|
45
|
+
"sample",
|
|
46
|
+
"shuffle",
|
|
47
|
+
"triangular",
|
|
48
|
+
"uniform",
|
|
49
|
+
"vonmisesvariate",
|
|
50
|
+
"weibullvariate",
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
_REPEAT_NODES = (ast.For, ast.AsyncFor, ast.While, ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class _RandomAliases:
|
|
58
|
+
modules: set[str]
|
|
59
|
+
functions: set[str]
|
|
60
|
+
seeds: set[str]
|
|
61
|
+
constructors: set[str]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _bound_target_names(node: ast.AST) -> set[str]:
|
|
65
|
+
match node:
|
|
66
|
+
case ast.Name(id=name):
|
|
67
|
+
return {name}
|
|
68
|
+
case ast.Tuple() | ast.List():
|
|
69
|
+
names: set[str] = set()
|
|
70
|
+
for element in node.elts:
|
|
71
|
+
names.update(_bound_target_names(element))
|
|
72
|
+
return names
|
|
73
|
+
case ast.Starred(value=value):
|
|
74
|
+
return _bound_target_names(value)
|
|
75
|
+
case _:
|
|
76
|
+
return set()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _scope_binding_events(statements: list[ast.stmt]) -> list[str]:
|
|
80
|
+
bindings: list[str] = []
|
|
81
|
+
stack: list[ast.AST] = [*reversed(statements)]
|
|
82
|
+
while stack:
|
|
83
|
+
node = stack.pop()
|
|
84
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
85
|
+
bindings.append(node.name)
|
|
86
|
+
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
87
|
+
bindings.extend(alias.asname or alias.name.split(".")[0] for alias in node.names)
|
|
88
|
+
elif isinstance(node, (ast.Assign, ast.Delete)):
|
|
89
|
+
for target in node.targets:
|
|
90
|
+
bindings.extend(_bound_target_names(target))
|
|
91
|
+
elif isinstance(node, (ast.AnnAssign, ast.AugAssign, ast.NamedExpr, ast.For, ast.AsyncFor)):
|
|
92
|
+
bindings.extend(_bound_target_names(node.target))
|
|
93
|
+
elif isinstance(node, (ast.ExceptHandler, ast.MatchAs, ast.MatchStar)) and node.name is not None:
|
|
94
|
+
bindings.append(node.name)
|
|
95
|
+
elif isinstance(node, ast.MatchMapping) and node.rest is not None:
|
|
96
|
+
bindings.append(node.rest)
|
|
97
|
+
elif isinstance(node, (ast.With, ast.AsyncWith)):
|
|
98
|
+
for item in node.items:
|
|
99
|
+
if item.optional_vars is not None:
|
|
100
|
+
bindings.extend(_bound_target_names(item.optional_vars))
|
|
101
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
|
|
102
|
+
continue
|
|
103
|
+
stack.extend(reversed(list(ast.iter_child_nodes(node))))
|
|
104
|
+
return bindings
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _scope_bindings(statements: list[ast.stmt]) -> set[str]:
|
|
108
|
+
return set(_scope_binding_events(statements))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _module_binding_counts(tree: ast.Module) -> dict[str, int]:
|
|
112
|
+
counts: dict[str, int] = {}
|
|
113
|
+
for name in _scope_binding_events(tree.body):
|
|
114
|
+
counts[name] = counts.get(name, 0) + 1
|
|
115
|
+
return counts
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _random_aliases(tree: ast.Module) -> _RandomAliases:
|
|
119
|
+
modules: set[str] = set()
|
|
120
|
+
functions: set[str] = set()
|
|
121
|
+
seeds: set[str] = set()
|
|
122
|
+
constructors: set[str] = set()
|
|
123
|
+
binding_counts = _module_binding_counts(tree)
|
|
124
|
+
for node in tree.body:
|
|
125
|
+
if isinstance(node, ast.Import):
|
|
126
|
+
modules.update(
|
|
127
|
+
local
|
|
128
|
+
for alias in node.names
|
|
129
|
+
if alias.name == "random" and binding_counts.get(local := alias.asname or alias.name, 0) == 1
|
|
130
|
+
)
|
|
131
|
+
elif isinstance(node, ast.ImportFrom) and node.module == "random":
|
|
132
|
+
for alias in node.names:
|
|
133
|
+
local = alias.asname or alias.name
|
|
134
|
+
if binding_counts.get(local, 0) != 1:
|
|
135
|
+
continue
|
|
136
|
+
if alias.name in _PRNG_FUNCTIONS:
|
|
137
|
+
functions.add(local)
|
|
138
|
+
elif alias.name == "seed":
|
|
139
|
+
seeds.add(local)
|
|
140
|
+
elif alias.name == "Random":
|
|
141
|
+
constructors.add(local)
|
|
142
|
+
return _RandomAliases(modules, functions, seeds, constructors)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _is_prng_call(node: ast.Call, modules: set[str], functions: set[str], instances: set[str]) -> bool:
|
|
146
|
+
if isinstance(node.func, ast.Name):
|
|
147
|
+
return node.func.id in functions
|
|
148
|
+
return (
|
|
149
|
+
isinstance(node.func, ast.Attribute)
|
|
150
|
+
and node.func.attr in _PRNG_FUNCTIONS
|
|
151
|
+
and isinstance(node.func.value, ast.Name)
|
|
152
|
+
and node.func.value.id in modules | instances
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _unseeded_instances(statements: list[ast.stmt], modules: set[str], constructors: set[str]) -> set[str]:
|
|
157
|
+
binding_events = _scope_binding_events(statements)
|
|
158
|
+
instances: set[str] = set()
|
|
159
|
+
for statement in statements:
|
|
160
|
+
if not (isinstance(statement, (ast.Assign, ast.AnnAssign)) and isinstance(statement.value, ast.Call)):
|
|
161
|
+
continue
|
|
162
|
+
targets = statement.targets if isinstance(statement, ast.Assign) else (statement.target,)
|
|
163
|
+
if len(targets) != 1 or not isinstance(targets[0], ast.Name):
|
|
164
|
+
continue
|
|
165
|
+
func = statement.value.func
|
|
166
|
+
is_constructor = (isinstance(func, ast.Name) and func.id in constructors) or (
|
|
167
|
+
isinstance(func, ast.Attribute)
|
|
168
|
+
and func.attr == "Random"
|
|
169
|
+
and isinstance(func.value, ast.Name)
|
|
170
|
+
and func.value.id in modules
|
|
171
|
+
)
|
|
172
|
+
if not is_constructor:
|
|
173
|
+
continue
|
|
174
|
+
args = statement.value.args
|
|
175
|
+
deterministic = (
|
|
176
|
+
len(args) == 1
|
|
177
|
+
and not statement.value.keywords
|
|
178
|
+
and isinstance(args[0], ast.Constant)
|
|
179
|
+
and args[0].value is not None
|
|
180
|
+
)
|
|
181
|
+
if not deterministic and binding_events.count(targets[0].id) == 1:
|
|
182
|
+
instances.add(targets[0].id)
|
|
183
|
+
return instances
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _is_seed_call(node: ast.Call, modules: set[str], seeds: set[str]) -> bool:
|
|
187
|
+
return (isinstance(node.func, ast.Name) and node.func.id in seeds) or (
|
|
188
|
+
isinstance(node.func, ast.Attribute)
|
|
189
|
+
and node.func.attr == "seed"
|
|
190
|
+
and isinstance(node.func.value, ast.Name)
|
|
191
|
+
and node.func.value.id in modules
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _has_deterministic_seed(node: ast.Call, modules: set[str], seeds: set[str]) -> bool:
|
|
196
|
+
return (
|
|
197
|
+
_is_seed_call(node, modules, seeds)
|
|
198
|
+
and len(node.args) == 1
|
|
199
|
+
and not node.keywords
|
|
200
|
+
and isinstance(node.args[0], ast.Constant)
|
|
201
|
+
and node.args[0].value is not None
|
|
202
|
+
and isinstance(node.args[0].value, (int, float, str, bytes))
|
|
203
|
+
and not isinstance(node.args[0].value, bool)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _test_nodes(test: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[ast.AST]:
|
|
208
|
+
stack: list[ast.AST] = [*reversed(test.body)]
|
|
209
|
+
while stack:
|
|
210
|
+
node = stack.pop()
|
|
211
|
+
yield node
|
|
212
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
|
|
213
|
+
continue
|
|
214
|
+
stack.extend(reversed(list(ast.iter_child_nodes(node))))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _bounded_nodes(root: ast.AST) -> Iterator[ast.AST]:
|
|
218
|
+
stack = [root]
|
|
219
|
+
while stack:
|
|
220
|
+
node = stack.pop()
|
|
221
|
+
yield node
|
|
222
|
+
if node is not root and isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
|
|
223
|
+
continue
|
|
224
|
+
stack.extend(reversed(list(ast.iter_child_nodes(node))))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _test_nodes_for_statement(statement: ast.stmt) -> Iterator[ast.AST]:
|
|
228
|
+
yield from _bounded_nodes(statement)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _explicitly_disabled(statements: list[ast.stmt]) -> bool:
|
|
232
|
+
for statement in statements:
|
|
233
|
+
if not isinstance(statement, (ast.Assign, ast.AnnAssign)):
|
|
234
|
+
continue
|
|
235
|
+
targets = statement.targets if isinstance(statement, ast.Assign) else (statement.target,)
|
|
236
|
+
if (
|
|
237
|
+
any(isinstance(target, ast.Name) and target.id == "__test__" for target in targets)
|
|
238
|
+
and isinstance(statement.value, ast.Constant)
|
|
239
|
+
and statement.value.value is False
|
|
240
|
+
):
|
|
241
|
+
return True
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _is_fixture(test: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
246
|
+
return any(
|
|
247
|
+
(isinstance(decorator, ast.Name) and decorator.id == "fixture")
|
|
248
|
+
or (isinstance(decorator, ast.Attribute) and decorator.attr == "fixture")
|
|
249
|
+
or (
|
|
250
|
+
isinstance(decorator, ast.Call)
|
|
251
|
+
and (
|
|
252
|
+
(isinstance(decorator.func, ast.Name) and decorator.func.id == "fixture")
|
|
253
|
+
or (isinstance(decorator.func, ast.Attribute) and decorator.func.attr == "fixture")
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
for decorator in test.decorator_list
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _collected_tests(tree: ast.Module) -> Iterator[ast.FunctionDef | ast.AsyncFunctionDef]:
|
|
261
|
+
if _explicitly_disabled(tree.body):
|
|
262
|
+
return
|
|
263
|
+
for node in tree.body:
|
|
264
|
+
if (
|
|
265
|
+
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
266
|
+
and node.name.startswith("test_")
|
|
267
|
+
and not _is_fixture(node)
|
|
268
|
+
):
|
|
269
|
+
yield node
|
|
270
|
+
elif isinstance(node, ast.ClassDef) and node.name.startswith("Test") and not _explicitly_disabled(node.body):
|
|
271
|
+
yield from (
|
|
272
|
+
child
|
|
273
|
+
for child in node.body
|
|
274
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
275
|
+
and child.name.startswith("test_")
|
|
276
|
+
and not _is_fixture(child)
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _is_collected_module(path: Path) -> bool:
|
|
281
|
+
return (path.name.startswith("test_") or path.name.endswith("_test.py")) and "scripts" not in path.parts
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _known_at_most_once(node: ast.expr) -> bool:
|
|
285
|
+
if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
|
|
286
|
+
return len(node.elts) <= 1
|
|
287
|
+
if isinstance(node, ast.Dict):
|
|
288
|
+
return len(node.keys) <= 1
|
|
289
|
+
if not (
|
|
290
|
+
isinstance(node, ast.Call)
|
|
291
|
+
and isinstance(node.func, ast.Name)
|
|
292
|
+
and node.func.id == "range"
|
|
293
|
+
and not node.keywords
|
|
294
|
+
and all(isinstance(arg, ast.Constant) and isinstance(arg.value, int) for arg in node.args)
|
|
295
|
+
):
|
|
296
|
+
return False
|
|
297
|
+
values = [arg.value for arg in node.args if isinstance(arg, ast.Constant) and isinstance(arg.value, int)]
|
|
298
|
+
try:
|
|
299
|
+
return len(range(*values)) <= 1
|
|
300
|
+
except TypeError, ValueError:
|
|
301
|
+
return False
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _repeated_regions(node: ast.AST) -> tuple[ast.AST, ...]:
|
|
305
|
+
if isinstance(node, (ast.For, ast.AsyncFor)):
|
|
306
|
+
return (
|
|
307
|
+
()
|
|
308
|
+
if _known_at_most_once(node.iter)
|
|
309
|
+
else tuple(
|
|
310
|
+
statement
|
|
311
|
+
for statement in node.body
|
|
312
|
+
if not isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
if isinstance(node, ast.While):
|
|
316
|
+
if isinstance(node.test, ast.Constant) and not node.test.value:
|
|
317
|
+
return ()
|
|
318
|
+
return (
|
|
319
|
+
node.test,
|
|
320
|
+
*(
|
|
321
|
+
statement
|
|
322
|
+
for statement in node.body
|
|
323
|
+
if not isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
if not isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)):
|
|
327
|
+
return ()
|
|
328
|
+
regions: list[ast.AST] = []
|
|
329
|
+
prior_generator_repeats = False
|
|
330
|
+
for generator in node.generators:
|
|
331
|
+
if prior_generator_repeats:
|
|
332
|
+
regions.append(generator.iter)
|
|
333
|
+
this_generator_repeats = not _known_at_most_once(generator.iter)
|
|
334
|
+
if prior_generator_repeats or this_generator_repeats:
|
|
335
|
+
regions.extend(generator.ifs)
|
|
336
|
+
prior_generator_repeats = prior_generator_repeats or this_generator_repeats
|
|
337
|
+
if prior_generator_repeats:
|
|
338
|
+
if isinstance(node, ast.DictComp):
|
|
339
|
+
regions.extend((node.key, node.value))
|
|
340
|
+
else:
|
|
341
|
+
regions.append(node.elt)
|
|
342
|
+
return tuple(regions)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _aliases_for_test(test: ast.FunctionDef | ast.AsyncFunctionDef, module_aliases: _RandomAliases) -> _RandomAliases:
|
|
346
|
+
local_aliases = _random_aliases(ast.Module(body=test.body, type_ignores=[]))
|
|
347
|
+
local_bindings = _scope_bindings(test.body) | {
|
|
348
|
+
arg.arg for arg in (*test.args.posonlyargs, *test.args.args, *test.args.kwonlyargs)
|
|
349
|
+
}
|
|
350
|
+
if test.args.vararg is not None:
|
|
351
|
+
local_bindings.add(test.args.vararg.arg)
|
|
352
|
+
if test.args.kwarg is not None:
|
|
353
|
+
local_bindings.add(test.args.kwarg.arg)
|
|
354
|
+
local_alias_names = (
|
|
355
|
+
local_aliases.modules | local_aliases.functions | local_aliases.seeds | local_aliases.constructors
|
|
356
|
+
)
|
|
357
|
+
shadowed = local_bindings - local_alias_names
|
|
358
|
+
return _RandomAliases(
|
|
359
|
+
modules=(module_aliases.modules - shadowed) | local_aliases.modules,
|
|
360
|
+
functions=(module_aliases.functions - shadowed) | local_aliases.functions,
|
|
361
|
+
seeds=(module_aliases.seeds - shadowed) | local_aliases.seeds,
|
|
362
|
+
constructors=(module_aliases.constructors - shadowed) | local_aliases.constructors,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@final
|
|
367
|
+
class UncontrolledRandomnessInTest(Rule):
|
|
368
|
+
id = "no-repeated-unseeded-stdlib-random-in-test"
|
|
369
|
+
code = "SARJ410"
|
|
370
|
+
documentation = RuleDocumentation(
|
|
371
|
+
summary="A collected test may repeatedly sample an unseeded standard-library PRNG.",
|
|
372
|
+
rationale=(
|
|
373
|
+
"Repeated unseeded sampling creates unreproducible generated inputs, making failures harder to replay "
|
|
374
|
+
"and debug when sampled values affect behavior."
|
|
375
|
+
),
|
|
376
|
+
remediation=(
|
|
377
|
+
"Use an isolated deterministic generator such as `rng = random.Random(17)`, inject one, or use a "
|
|
378
|
+
"property framework that records failing seeds."
|
|
379
|
+
),
|
|
380
|
+
category=RuleCategory.TESTING,
|
|
381
|
+
autofix=AutofixPolicy.NONE,
|
|
382
|
+
aliases=("uncontrolled-randomness-in-test",),
|
|
383
|
+
limitations=(
|
|
384
|
+
"Only standard-library `random` calls in potentially repeated loop or comprehension regions in default-collected test modules and callables are checked.",
|
|
385
|
+
"Single draws, injected RNG objects, Hypothesis tests, and cryptographic randomness are excluded.",
|
|
386
|
+
"Only a statically deterministic, unconditional seed before the repeated sample suppresses the finding; fixture, plugin, and interprocedural seeding are not inferred.",
|
|
387
|
+
),
|
|
388
|
+
examples=(
|
|
389
|
+
RuleExample(
|
|
390
|
+
example_id="seeded-random-sampling",
|
|
391
|
+
title="Make repeated random sampling reproducible",
|
|
392
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
393
|
+
files=(
|
|
394
|
+
ExampleFile.python(
|
|
395
|
+
"tests/test_selector.py",
|
|
396
|
+
"import random\n\ndef test_distribution():\n rng = random.Random(17)\n values = [rng.random() for _ in range(50)]\n assert min(values) < 0.5\n",
|
|
397
|
+
),
|
|
398
|
+
),
|
|
399
|
+
focus_path=PurePosixPath("tests/test_selector.py"),
|
|
400
|
+
expected_count=0,
|
|
401
|
+
public=True,
|
|
402
|
+
),
|
|
403
|
+
RuleExample(
|
|
404
|
+
example_id="unseeded-random-sampling",
|
|
405
|
+
title="Do not repeat probabilistic trials without reproducibility",
|
|
406
|
+
outcome=ExampleOutcome.MATCH,
|
|
407
|
+
files=(
|
|
408
|
+
ExampleFile.python(
|
|
409
|
+
"tests/test_selector.py",
|
|
410
|
+
"import random\n\ndef test_distribution():\n values = [random.random() for _ in range(50)]\n assert min(values) < 0.5\n",
|
|
411
|
+
),
|
|
412
|
+
),
|
|
413
|
+
focus_path=PurePosixPath("tests/test_selector.py"),
|
|
414
|
+
expected_count=1,
|
|
415
|
+
public=True,
|
|
416
|
+
),
|
|
417
|
+
),
|
|
418
|
+
)
|
|
419
|
+
description = documentation.summary
|
|
420
|
+
|
|
421
|
+
@override
|
|
422
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
423
|
+
if not is_test_path(path) or not _is_collected_module(path) or is_generated(path, source):
|
|
424
|
+
return []
|
|
425
|
+
tree = parse_or_none(path, source)
|
|
426
|
+
if tree is None:
|
|
427
|
+
return []
|
|
428
|
+
aliases = _random_aliases(tree)
|
|
429
|
+
findings: list[Diagnostic] = []
|
|
430
|
+
for test in _collected_tests(tree):
|
|
431
|
+
test_aliases = _aliases_for_test(test, aliases)
|
|
432
|
+
if not test_aliases.modules and not test_aliases.functions and not test_aliases.constructors:
|
|
433
|
+
continue
|
|
434
|
+
instances = _unseeded_instances(test.body, test_aliases.modules, test_aliases.constructors)
|
|
435
|
+
nodes = list(_test_nodes(test))
|
|
436
|
+
top_level_seed_indexes = {
|
|
437
|
+
index
|
|
438
|
+
for index, statement in enumerate(test.body)
|
|
439
|
+
if isinstance(statement, ast.Expr)
|
|
440
|
+
and isinstance(statement.value, ast.Call)
|
|
441
|
+
and _has_deterministic_seed(statement.value, test_aliases.modules, test_aliases.seeds)
|
|
442
|
+
}
|
|
443
|
+
for repeated in (node for node in nodes if isinstance(node, _REPEAT_NODES)):
|
|
444
|
+
owner_index = next(
|
|
445
|
+
(
|
|
446
|
+
index
|
|
447
|
+
for index, statement in enumerate(test.body)
|
|
448
|
+
if repeated is statement or repeated in _test_nodes_for_statement(statement)
|
|
449
|
+
),
|
|
450
|
+
None,
|
|
451
|
+
)
|
|
452
|
+
if owner_index is not None and any(seed_index < owner_index for seed_index in top_level_seed_indexes):
|
|
453
|
+
continue
|
|
454
|
+
calls = [
|
|
455
|
+
node
|
|
456
|
+
for region in _repeated_regions(repeated)
|
|
457
|
+
for node in _bounded_nodes(region)
|
|
458
|
+
if isinstance(node, ast.Call)
|
|
459
|
+
and _is_prng_call(node, test_aliases.modules, test_aliases.functions, instances)
|
|
460
|
+
]
|
|
461
|
+
if not calls:
|
|
462
|
+
continue
|
|
463
|
+
call = min(calls, key=lambda item: (item.lineno, item.col_offset))
|
|
464
|
+
findings.append(
|
|
465
|
+
Diagnostic(
|
|
466
|
+
path=path,
|
|
467
|
+
line=call.lineno,
|
|
468
|
+
col=call.col_offset + 1,
|
|
469
|
+
code=self.code,
|
|
470
|
+
severity=Severity.WARNING,
|
|
471
|
+
message=(
|
|
472
|
+
"a standard-library PRNG sample runs in a potentially repeated test region without a "
|
|
473
|
+
"dominating deterministic seed; use an isolated `random.Random(seed)`, inject a "
|
|
474
|
+
"deterministic RNG, or use a property-testing framework with recorded seeds."
|
|
475
|
+
),
|
|
476
|
+
)
|
|
477
|
+
)
|
|
478
|
+
break
|
|
479
|
+
unique = {(finding.line, finding.col): finding for finding in findings}
|
|
480
|
+
return [unique[key] for key in sorted(unique)]
|