sarj-python-lint 0.69.0__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.
Files changed (108) hide show
  1. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_registry.py +2 -0
  4. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/duplicate_test_body.py +9 -0
  5. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +2 -2
  6. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +109 -26
  7. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +111 -12
  8. sarj_python_lint-0.70.0/src/sarj_python_lint/rules/repeated_static_call_cases.py +298 -0
  9. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +125 -11
  10. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/.gitignore +0 -0
  11. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/LICENSE +0 -0
  12. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/README.md +0 -0
  13. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/__init__.py +0 -0
  14. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/__main__.py +0 -0
  15. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/_filesystem.py +0 -0
  16. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  17. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/_secret_names.py +0 -0
  18. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/_version.py +0 -0
  19. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/py.typed +0 -0
  20. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/ratchet.py +0 -0
  21. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rule_base.py +0 -0
  22. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  23. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  24. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  25. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  26. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  27. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  28. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  29. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  30. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  31. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  32. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  33. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  34. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  35. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  36. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +0 -0
  37. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
  38. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  39. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  40. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  41. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
  42. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  43. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  44. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  45. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  46. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  47. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  48. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  49. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  50. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  51. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  52. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  53. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  54. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  55. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  56. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  57. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  58. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  59. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
  60. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  61. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  62. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  63. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  64. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  65. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  66. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  67. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  68. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  69. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  70. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  71. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  72. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  73. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  74. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  75. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  76. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  77. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  78. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  79. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  80. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  81. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  82. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  83. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  84. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  85. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  86. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  87. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  88. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  89. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  90. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  91. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  92. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  93. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  94. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  95. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  96. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  97. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  98. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  99. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  100. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  101. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  102. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  103. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/source_coupled_test.py +0 -0
  104. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  105. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  106. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  107. {sarj_python_lint-0.69.0 → sarj_python_lint-0.70.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  108. {sarj_python_lint-0.69.0 → 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.69.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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.69.0"
3
+ version = "0.70.0"
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" }]
@@ -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:
@@ -18,6 +18,7 @@ from sarj_python_lint.rule_base import (
18
18
  RuleCategory,
19
19
  RuleDocumentation,
20
20
  RuleExample,
21
+ Severity,
21
22
  parse_or_none,
22
23
  )
23
24
  from sarj_python_lint.rules._paths import is_generated, is_test_path
@@ -29,7 +30,8 @@ if TYPE_CHECKING:
29
30
 
30
31
  _GENERIC_EXCEPTIONS = frozenset({"Exception", "BaseException"})
31
32
  _MIN_SENTINEL_COUNT = 2
32
- _MIN_TYPE_DISPATCH_BRANCHES = 3
33
+ _MIN_TYPE_DISPATCH_BRANCHES = 2
34
+ _ERROR_TYPE_DISPATCH_BRANCHES = 3
33
35
  _ISINSTANCE_ARG_COUNT = 2
34
36
  _PAIR_COUNT = 2
35
37
 
@@ -238,7 +240,7 @@ def _matchable_class_reference(
238
240
  case ast.Name(id=name):
239
241
  if name in runtime_tuple_aliases:
240
242
  return False
241
- return name in _MATCHABLE_BUILTIN_TYPES or (name[:1].isupper() and not name.isupper())
243
+ return name in _MATCHABLE_BUILTIN_TYPES or _looks_like_class_name(name)
242
244
  case ast.Attribute(value=value, attr=attribute):
243
245
  lowercase_ast_type = (
244
246
  isinstance(value, ast.Name)
@@ -246,15 +248,19 @@ def _matchable_class_reference(
246
248
  and attribute in _MATCHABLE_LOWERCASE_AST_TYPES
247
249
  )
248
250
  matchable_leaf = (
249
- attribute in _MATCHABLE_BUILTIN_TYPES
250
- or (attribute[:1].isupper() and not attribute.isupper())
251
- or lowercase_ast_type
251
+ attribute in _MATCHABLE_BUILTIN_TYPES or _looks_like_class_name(attribute) or lowercase_ast_type
252
252
  )
253
253
  return matchable_leaf and _is_dotted_name(value)
254
254
  case _:
255
255
  return False
256
256
 
257
257
 
258
+ def _looks_like_class_name(name: str) -> bool:
259
+ """Recognize ordinary and private CamelCase runtime class references."""
260
+ unprefixed = name.lstrip("_")
261
+ return bool(unprefixed) and unprefixed[:1].isupper() and not unprefixed.isupper()
262
+
263
+
258
264
  def _is_dotted_name(node: ast.expr) -> bool:
259
265
  """Report whether `node` is the prefix of a pattern-compatible dotted name."""
260
266
  if isinstance(node, ast.Name):
@@ -279,30 +285,74 @@ def _is_matchable_raise_guard(test: ast.expr, runtime_tuple_aliases: frozenset[s
279
285
  )
280
286
 
281
287
 
282
- def _isinstance_ladder(node: ast.If, runtime_tuple_aliases: frozenset[str]) -> tuple[str, int] | None:
288
+ def _isinstance_ladder(
289
+ node: ast.If,
290
+ runtime_tuple_aliases: frozenset[str],
291
+ ast_module_names: frozenset[str],
292
+ ) -> tuple[str, int] | None:
283
293
  """Recognize a complete if/elif chain dispatching one name by runtime type."""
294
+ branches = _if_ladder(node)
295
+ branch_count = len(branches)
296
+ if branch_count < _MIN_TYPE_DISPATCH_BRANCHES:
297
+ return None
298
+
284
299
  subject: str | None = None
285
- branch_count = 0
286
- current = node
287
- while True:
288
- branch_subject = _matchable_isinstance_test(current.test, runtime_tuple_aliases)
300
+ for branch in branches:
301
+ # Guarded ladders are part of the newly calibrated two-arm advisory.
302
+ # Preserve SARJ080's established 3+ ladder boundary to avoid promoting
303
+ # an uncalibrated population directly to blocking diagnostics.
304
+ branch_subject = (
305
+ _matchable_isinstance_dispatch(branch.test, runtime_tuple_aliases, frozenset())
306
+ if branch_count < _ERROR_TYPE_DISPATCH_BRANCHES
307
+ else _matchable_isinstance_test(branch.test, runtime_tuple_aliases)
308
+ )
289
309
  if branch_subject is None or (subject is not None and branch_subject != subject):
290
310
  return None
291
311
  subject = branch_subject
292
- branch_count += 1
293
312
 
294
- if not current.orelse:
295
- break
296
- if len(current.orelse) == 1 and isinstance(current.orelse[0], ast.If):
297
- current = current.orelse[0]
298
- continue
299
- break
300
-
301
- if branch_count < _MIN_TYPE_DISPATCH_BRANCHES:
313
+ if branch_count < _ERROR_TYPE_DISPATCH_BRANCHES and any(
314
+ _dispatch_uses_stdlib_ast(branch.test, ast_module_names) for branch in branches
315
+ ):
316
+ return None
317
+ if subject is None:
302
318
  return None
303
319
  return subject, branch_count
304
320
 
305
321
 
322
+ def _if_ladder(node: ast.If) -> tuple[ast.If, ...]:
323
+ """Return the complete maximal if/elif chain rooted at ``node``."""
324
+ branches = [node]
325
+ current = node
326
+ while len(current.orelse) == 1 and isinstance(current.orelse[0], ast.If):
327
+ current = current.orelse[0]
328
+ branches.append(current)
329
+ return tuple(branches)
330
+
331
+
332
+ def _dispatch_uses_stdlib_ast(test: ast.expr, ast_module_names: frozenset[str]) -> bool:
333
+ """Report whether a dispatch arm directly names a proven stdlib ``ast`` class."""
334
+ call = _leading_isinstance(test)
335
+ if call is None or len(call.args) != _ISINSTANCE_ARG_COUNT:
336
+ return False
337
+ return any(
338
+ isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and node.value.id in ast_module_names
339
+ for node in ast.walk(call.args[1])
340
+ )
341
+
342
+
343
+ def _leading_isinstance(test: ast.expr) -> ast.Call | None:
344
+ """Return the leading isinstance call from a plain or guarded dispatch arm."""
345
+ candidate = test.values[0] if isinstance(test, ast.BoolOp) and isinstance(test.op, ast.And) else test
346
+ if isinstance(candidate, ast.Call) and isinstance(candidate.func, ast.Name) and candidate.func.id == "isinstance":
347
+ return candidate
348
+ return None
349
+
350
+
351
+ def _type_dispatch_severity(branch_count: int) -> Severity:
352
+ """Keep newly expanded two-arm findings advisory while 3+ remains blocking."""
353
+ return Severity.WARNING if branch_count < _ERROR_TYPE_DISPATCH_BRANCHES else Severity.ERROR
354
+
355
+
306
356
  def _sequential_isinstance_dispatches(
307
357
  tree: ast.Module, path: Path, code: str, runtime_tuple_aliases: frozenset[str]
308
358
  ) -> list[Diagnostic]:
@@ -336,15 +386,28 @@ def _sequential_isinstance_dispatches(
336
386
  break
337
387
  end += 1
338
388
  branch_count = end - index
389
+ adjacent_if = (index > 0 and isinstance(statements[index - 1], ast.If)) or (
390
+ end < len(statements) and isinstance(statements[end], ast.If)
391
+ )
339
392
  guards = [_passthrough_guard_var(statement) for statement in statements[index:end]]
340
393
  owned_by_sentinel_arm = guards[:_MIN_SENTINEL_COUNT] == [subject] * _MIN_SENTINEL_COUNT
341
- if branch_count >= _MIN_TYPE_DISPATCH_BRANCHES and not owned_by_sentinel_arm:
394
+ exact_two_ast_dispatch = branch_count < _ERROR_TYPE_DISPATCH_BRANCHES and any(
395
+ isinstance(statement, ast.If) and _dispatch_uses_stdlib_ast(statement.test, ast_module_names)
396
+ for statement in statements[index:end]
397
+ )
398
+ if (
399
+ branch_count >= _MIN_TYPE_DISPATCH_BRANCHES
400
+ and not adjacent_if
401
+ and not owned_by_sentinel_arm
402
+ and not exact_two_ast_dispatch
403
+ ):
342
404
  findings.append(
343
405
  Diagnostic(
344
406
  path=path,
345
407
  line=first.lineno,
346
408
  col=first.col_offset + 1,
347
409
  code=code,
410
+ severity=_type_dispatch_severity(branch_count),
348
411
  message=(
349
412
  f"{branch_count}-branch terminating isinstance dispatch on '{subject}' — use "
350
413
  "match/case class patterns so the exclusive type dispatch is explicit."
@@ -396,16 +459,24 @@ def _matchable_isinstance_dispatch(
396
459
  ) -> str | None:
397
460
  """Extract a type-dispatch subject, preserving any trailing ``and`` terms as a case guard."""
398
461
  match test:
399
- case ast.BoolOp(op=ast.And(), values=[leading_isinstance, *_guard_terms]):
400
- return _matchable_isinstance_test(
462
+ case ast.BoolOp(op=ast.And(), values=[leading_isinstance, *guard_terms]):
463
+ subject = _matchable_isinstance_test(
401
464
  leading_isinstance,
402
465
  runtime_tuple_aliases,
403
466
  ast_module_names=ast_module_names,
404
467
  )
468
+ if subject is None or any(_rebinds_name(term, subject) for term in guard_terms):
469
+ return None
470
+ return subject
405
471
  case _:
406
472
  return _matchable_isinstance_test(test, runtime_tuple_aliases)
407
473
 
408
474
 
475
+ def _rebinds_name(node: ast.AST, name: str) -> bool:
476
+ """Reject guards that change the dispatch subject after ``match`` would snapshot it."""
477
+ return any(isinstance(candidate, ast.NamedExpr) and candidate.target.id == name for candidate in ast.walk(node))
478
+
479
+
409
480
  def _body_terminates(body: list[ast.stmt]) -> bool:
410
481
  """Prove a branch cannot fall through to the following sibling statement."""
411
482
  statements = [statement for statement in body if not _is_docstring(statement)]
@@ -419,6 +490,13 @@ def _body_terminates(body: list[ast.stmt]) -> bool:
419
490
  return bool(orelse) and _body_terminates(body) and _body_terminates(orelse)
420
491
  case ast.With() | ast.AsyncWith():
421
492
  return _body_terminates(last.body)
493
+ case ast.Try() | ast.TryStar():
494
+ if last.finalbody and _body_terminates(last.finalbody):
495
+ return True
496
+ normal_path_terminates = _body_terminates(last.body) or (
497
+ bool(last.orelse) and _body_terminates(last.orelse)
498
+ )
499
+ return normal_path_terminates and all(_body_terminates(handler.body) for handler in last.handlers)
422
500
  case _:
423
501
  return False
424
502
 
@@ -683,12 +761,14 @@ class _TypeDispatchVisitor(ast.NodeVisitor):
683
761
  report_control_flow_raise: bool,
684
762
  report_isinstance_ladder: bool,
685
763
  runtime_tuple_aliases: frozenset[str],
764
+ ast_module_names: frozenset[str],
686
765
  ) -> None:
687
766
  self.path: Path = path
688
767
  self.code: str = code
689
768
  self.report_control_flow_raise: bool = report_control_flow_raise
690
769
  self.report_isinstance_ladder: bool = report_isinstance_ladder
691
770
  self.runtime_tuple_aliases: frozenset[str] = runtime_tuple_aliases
771
+ self.ast_module_names: frozenset[str] = ast_module_names
692
772
  self.diags: list[Diagnostic] = []
693
773
  self.try_stack: list[ast.Try | ast.TryStar] = []
694
774
  self.if_stack: list[ast.If] = []
@@ -700,7 +780,7 @@ class _TypeDispatchVisitor(ast.NodeVisitor):
700
780
  while len(continuation.orelse) == 1 and isinstance(continuation.orelse[0], ast.If):
701
781
  continuation = continuation.orelse[0]
702
782
  self.ladder_continuations.add(id(continuation))
703
- ladder = _isinstance_ladder(node, self.runtime_tuple_aliases)
783
+ ladder = _isinstance_ladder(node, self.runtime_tuple_aliases, self.ast_module_names)
704
784
  if ladder is not None:
705
785
  subject, branch_count = ladder
706
786
  self.diags.append(
@@ -709,6 +789,7 @@ class _TypeDispatchVisitor(ast.NodeVisitor):
709
789
  line=node.lineno,
710
790
  col=node.col_offset + 1,
711
791
  code=self.code,
792
+ severity=_type_dispatch_severity(branch_count),
712
793
  message=(
713
794
  f"{branch_count}-branch isinstance dispatch on '{subject}' — use match/case "
714
795
  "class patterns (combining tuple members with `|`) so the type dispatch is explicit."
@@ -815,14 +896,14 @@ class PreferMatchTypeDispatch(Rule):
815
896
  id: str = "prefer-match-type-dispatch"
816
897
  code: str = "SARJ080"
817
898
  documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
818
- summary="Use `match` for explicit runtime type dispatch instead of branching parser machinery.",
899
+ summary="Use `match` for explicit runtime type dispatch instead of repeated `isinstance` branches or parser machinery.",
819
900
  rationale="Pattern matching makes type cases and sentinel cases explicit without control-flow exceptions or repeated dispatch checks.",
820
901
  remediation="Replace the detected guard or exception-driven dispatch with `match` arms for each supported value shape.",
821
902
  category=RuleCategory.MAINTAINABILITY,
822
903
  autofix=AutofixPolicy.NONE,
823
904
  limitations=(
824
- "The rule targets several measured shapes, including control-flow raises, sequential guards, and repeated `isinstance` dispatch.",
825
- "Generated files, runtime tuple aliases, idiomatic None/Unset prologues, and code that shadows `isinstance` are excluded; test files omit the control-flow-raise check.",
905
+ "Safe two-branch `isinstance` dispatch is advisory; dispatch with three or more branches and the rule's established parser shapes remain blocking.",
906
+ "Generated files, runtime tuple aliases, two-arm stdlib AST visitors, subject-rebinding guards, idiomatic None/Unset prologues, and code that shadows `isinstance` are excluded; test files omit the control-flow-raise check.",
826
907
  ),
827
908
  examples=(
828
909
  RuleExample(
@@ -881,6 +962,7 @@ class PreferMatchTypeDispatch(Rule):
881
962
  if tree is None:
882
963
  return []
883
964
  runtime_tuple_aliases = _runtime_tuple_aliases(tree)
965
+ ast_module_names = _stdlib_ast_module_names(tree)
884
966
 
885
967
  visitor = _TypeDispatchVisitor(
886
968
  path,
@@ -888,6 +970,7 @@ class PreferMatchTypeDispatch(Rule):
888
970
  report_control_flow_raise=not is_test_path(path),
889
971
  report_isinstance_ladder=not _shadows_isinstance(tree),
890
972
  runtime_tuple_aliases=runtime_tuple_aliases,
973
+ ast_module_names=ast_module_names,
891
974
  )
892
975
  visitor.visit(tree)
893
976
  if not _shadows_isinstance(tree):
@@ -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 _imported_bindings(tree: ast.Module) -> set[str]:
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(alias.asname or alias.name for alias in node.names if alias.name != "*")
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(decorator: ast.expr) -> ast.expr | None:
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
- "EXPECTED_MODELS = ('a', 'b')\n\n@pytest.mark.parametrize('model', EXPECTED_MODELS)\ndef test_model(model):\n assert build(model).tier == 'priority'\n",
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
- "from app.models import ELIGIBLE_MODELS\n\n@pytest.mark.parametrize('model', ELIGIBLE_MODELS)\ndef test_model(model):\n assert build(model).tier == 'priority'\n",
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
- imported = _imported_bindings(tree)
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 ast.walk(tree):
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)