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.
Files changed (123) hide show
  1. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/pyproject.toml +1 -1
  3. sarj_python_lint-0.80.13/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +480 -0
  4. sarj_python_lint-0.80.12/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -290
  5. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/.gitignore +0 -0
  6. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/LICENSE +0 -0
  7. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/README.md +0 -0
  8. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/__init__.py +0 -0
  9. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/__main__.py +0 -0
  10. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/_analysis_session.py +0 -0
  11. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/_filesystem.py +0 -0
  12. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  13. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/_secret_names.py +0 -0
  14. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/_version.py +0 -0
  15. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/py.typed +0 -0
  16. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/ratchet.py +0 -0
  17. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rule_base.py +0 -0
  18. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/__init__.py +0 -0
  19. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  20. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_comments.py +0 -0
  21. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  22. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  23. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_first_party.py +0 -0
  24. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  25. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_imports.py +0 -0
  26. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_logging.py +0 -0
  27. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_paths.py +0 -0
  28. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_project_index.py +0 -0
  29. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  30. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_pytest.py +0 -0
  31. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_registry.py +0 -0
  32. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_sql.py +0 -0
  33. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  34. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  35. {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
  36. {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
  37. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  38. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  39. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  40. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  41. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  42. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  43. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  44. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  45. {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
  46. {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
  47. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  48. {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
  49. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  50. {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
  51. {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
  52. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  53. {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
  54. {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
  55. {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
  56. {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
  57. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  58. {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
  59. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  60. {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
  61. {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
  62. {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
  63. {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
  64. {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
  65. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  66. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  67. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  68. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  69. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  70. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  71. {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
  72. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  73. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  74. {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
  75. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  76. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  77. {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
  78. {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
  79. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  80. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  81. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  82. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  83. {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
  84. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  85. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  86. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  87. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  88. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  89. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  90. {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
  91. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  92. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  93. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  94. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  95. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  96. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  97. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  98. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  99. {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
  100. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  101. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  102. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  103. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  104. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  105. {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
  106. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  107. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  108. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  109. {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
  110. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  111. {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
  112. {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
  113. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  114. {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
  115. {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
  116. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  117. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/stepdown.py +0 -0
  118. {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
  119. {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
  120. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  121. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  122. {sarj_python_lint-0.80.12 → sarj_python_lint-0.80.13}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  123. {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.12
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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.80.12"
3
+ version = "0.80.13"
4
4
  description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "sarj-ai" }]
@@ -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)]