sarj-python-lint 0.81.8__tar.gz → 0.81.11__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 (127) hide show
  1. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +23 -7
  4. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +4 -151
  5. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +57 -24
  6. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/.gitignore +0 -0
  7. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/LICENSE +0 -0
  8. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/README.md +0 -0
  9. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/__init__.py +0 -0
  10. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/__main__.py +0 -0
  11. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/_analysis_session.py +0 -0
  12. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/_filesystem.py +0 -0
  13. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/_python_target.py +0 -0
  14. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  15. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/_secret_names.py +0 -0
  16. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/_version.py +0 -0
  17. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/py.typed +0 -0
  18. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/ratchet.py +0 -0
  19. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rule_base.py +0 -0
  20. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/__init__.py +0 -0
  21. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  22. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/_comments.py +0 -0
  23. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  24. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  25. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/_first_party.py +0 -0
  26. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  27. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/_imports.py +0 -0
  28. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/_logging.py +0 -0
  29. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/_paths.py +0 -0
  30. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/_project_index.py +0 -0
  31. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  32. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/_pytest.py +0 -0
  33. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/_registry.py +0 -0
  34. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/_sql.py +0 -0
  35. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  36. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  37. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  38. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  39. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  40. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  41. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  42. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  43. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  44. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  45. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  46. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  47. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  48. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  49. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  50. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  51. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  52. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  53. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  54. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  55. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  56. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  57. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  58. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  59. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  60. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  61. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  62. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  63. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  64. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  65. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  66. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  67. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  68. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  69. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  70. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  71. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  72. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  73. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  74. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  75. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  76. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  77. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  78. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  79. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  80. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  81. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  82. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  83. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  84. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  85. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  86. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  87. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  88. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  89. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  90. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  91. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  92. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  93. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  94. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  95. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  96. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  97. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  98. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  99. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  100. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  101. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  102. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  103. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  104. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  105. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  106. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  107. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  108. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  109. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  110. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  111. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  112. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  113. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  114. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  115. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  116. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  117. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  118. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  119. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  120. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/stepdown.py +0 -0
  121. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  122. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  123. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  124. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  125. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  126. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  127. {sarj_python_lint-0.81.8 → sarj_python_lint-0.81.11}/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.81.8
3
+ Version: 0.81.11
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.81.8"
3
+ version = "0.81.11"
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" }]
@@ -42,7 +42,7 @@ class NoRawConnectionInTests(Rule):
42
42
  category=RuleCategory.TESTING,
43
43
  autofix=AutofixPolicy.NONE,
44
44
  limitations=(
45
- "Only test paths outside conftest.py and conventional shared test-support modules are inspected.",
45
+ "Only collected test modules outside conftest.py, conventional shared test-support modules, and migration-test trees are inspected.",
46
46
  "A receiver is reported only when a parameter, annotated local, or constructor call proves it is a psycopg ConnectionPool or AsyncConnectionPool.",
47
47
  "Pytest fixtures may use a connection internally for setup and cleanup, but fixtures that return or yield the connection remain reportable.",
48
48
  ),
@@ -81,12 +81,16 @@ class NoRawConnectionInTests(Rule):
81
81
 
82
82
  @override
83
83
  def check(self, path: Path, source: str) -> list[Diagnostic]:
84
- if (
85
- not is_test_path(path)
86
- or path.name == "conftest.py"
87
- or is_test_support_path(path)
88
- or is_generated(path, source)
89
- ):
84
+ excluded_path = any(
85
+ (
86
+ not is_test_path(path),
87
+ path.name == "conftest.py",
88
+ is_test_support_path(path),
89
+ _is_non_collected_test_support(path),
90
+ _is_migration_test(path),
91
+ )
92
+ )
93
+ if excluded_path or is_generated(path, source):
90
94
  return []
91
95
  tree = parse_or_none(path, source)
92
96
  if tree is None:
@@ -120,6 +124,18 @@ class NoRawConnectionInTests(Rule):
120
124
  return sorted(diagnostics, key=lambda item: (item.line, item.col))
121
125
 
122
126
 
127
+ def _is_non_collected_test_support(path: Path) -> bool:
128
+ return "tests" in path.parts and not (path.name.startswith("test_") or path.name.endswith("_test.py"))
129
+
130
+
131
+ def _is_migration_test(path: Path) -> bool:
132
+ parts = path.parts
133
+ return any(
134
+ part == "tests" and index + 1 < len(parts) and parts[index + 1] == "migrations"
135
+ for index, part in enumerate(parts)
136
+ )
137
+
138
+
123
139
  def _proven_pool_names(scope: ast.Module | ast.FunctionDef | ast.AsyncFunctionDef) -> frozenset[str]:
124
140
  names: set[str] = set()
125
141
  if isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef)):
@@ -26,7 +26,6 @@ if TYPE_CHECKING:
26
26
 
27
27
  _MIN_BRANCHES = 3
28
28
  _ISINSTANCE_ARGS = 2
29
- _NESTED_TYPE_CHECKS = 2
30
29
  _BUILTIN_TYPES = frozenset(
31
30
  {
32
31
  "bool",
@@ -88,24 +87,21 @@ class PreferMatchTypeDispatch(Rule):
88
87
  id: str = "prefer-match-type-dispatch"
89
88
  code: str = "SARJ080"
90
89
  documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
91
- summary="Prefer `match` for structural runtime type dispatch and nested class guards.",
90
+ summary="Prefer `match` for structural runtime type dispatch.",
92
91
  rationale=(
93
92
  "A long mutually exclusive `isinstance` dispatch repeats its subject and hides the closed list of "
94
- "runtime shapes. A chained guard that separately checks an object and one of its attributes similarly "
95
- "spells one structural invariant as unrelated boolean calls; class patterns express both shapes and "
96
- "can bind the narrowed values in one construct."
93
+ "runtime shapes. Class patterns make the alternatives explicit without repeating the dispatch subject."
97
94
  ),
98
95
  remediation=(
99
96
  "Replace the branches with `match subject` and one class-pattern arm per distinct type; combine types "
100
- "that share behavior with an OR-pattern. For a nested guard, use a nested class pattern and bind any "
101
- "narrowed attribute that later code consumes."
97
+ "that share behavior with an OR-pattern."
102
98
  ),
103
99
  category=RuleCategory.MAINTAINABILITY,
104
100
  autofix=AutofixPolicy.NONE,
105
101
  limitations=(
106
102
  "General dispatch requires three or more adjacent, unguarded `isinstance` branches over the same simple name. Two arms are checked only for an exact `ast.Name.id` / `ast.Attribute.attr` projection followed by a None-returning fallback.",
107
- "Nested-guard findings require exactly two negated checks joined by `or`: an imported or module-local class for a simple name, followed by an imported or module-local class for an attribute rooted at that name, with an unconditional built-in `TypeError` raise.",
108
103
  "The checked types must be unshadowed builtins, unshadowed module-local classes, or proven stdlib ast classes; unresolved imports, runtime type groups, repeated type references, generated files, and non-terminating sibling checks are excluded.",
104
+ "Nested attribute-validation guards are excluded: converting them to keyword class patterns can turn attribute errors into match fallthrough, and an imported isinstance operand may be a runtime tuple rather than a class.",
109
105
  "A terminal-looking context-manager body does not prove a sibling branch terminates: exceptions can be suppressed. An unconditional return or raise after the context manager remains eligible.",
110
106
  "Declared support for Python before 3.10 suppresses this recommendation when proven by the nearest project metadata or exact installed-distribution ownership. Missing or ambiguous target metadata retains advisory behavior; it does not prove a modern target.",
111
107
  ),
@@ -154,47 +150,6 @@ class PreferMatchTypeDispatch(Rule):
154
150
  expected_count=0,
155
151
  public=True,
156
152
  ),
157
- RuleExample(
158
- example_id="nested-class-type-guard",
159
- title="Separate type checks describe one nested runtime shape",
160
- outcome=ExampleOutcome.MATCH,
161
- scenario="nested-class-guard",
162
- files=(
163
- ExampleFile.python(
164
- "app/scheduler.py",
165
- "from app.models import ActiveSettings, CustomScenario\n\n"
166
- "def schedule(settings):\n"
167
- " if not isinstance(settings, ActiveSettings) or not isinstance(settings.scenario, CustomScenario):\n"
168
- " raise TypeError('custom scenario required')\n"
169
- " return settings.scenario.id\n",
170
- ),
171
- ),
172
- focus_path=PurePosixPath("app/scheduler.py"),
173
- expected_count=1,
174
- public=True,
175
- ),
176
- RuleExample(
177
- example_id="nested-class-pattern",
178
- title="One class pattern checks and binds the nested runtime shape",
179
- outcome=ExampleOutcome.NO_MATCH,
180
- scenario="nested-class-guard",
181
- files=(
182
- ExampleFile.python(
183
- "app/scheduler.py",
184
- "from app.models import ActiveSettings, CustomScenario\n\n"
185
- "def schedule(settings):\n"
186
- " match settings:\n"
187
- " case ActiveSettings(scenario=CustomScenario() as scenario):\n"
188
- " pass\n"
189
- " case _:\n"
190
- " raise TypeError('custom scenario required')\n"
191
- " return scenario.id\n",
192
- ),
193
- ),
194
- focus_path=PurePosixPath("app/scheduler.py"),
195
- expected_count=0,
196
- public=True,
197
- ),
198
153
  RuleExample(
199
154
  example_id="two-arm-ast-projection",
200
155
  title="Two isinstance returns project one AST identifier shape",
@@ -277,16 +232,6 @@ class PreferMatchTypeDispatch(Rule):
277
232
  unsafe_bindings=unsafe_bindings,
278
233
  )
279
234
  )
280
- findings.extend(
281
- _nested_guard_findings(
282
- all_nodes,
283
- path,
284
- self.code,
285
- imports,
286
- local_classes,
287
- unsafe_bindings=unsafe_bindings,
288
- )
289
- )
290
235
  findings.extend(
291
236
  _two_arm_ast_projection_findings(
292
237
  all_nodes,
@@ -441,62 +386,6 @@ def _dispatch(
441
386
  return _Dispatch(subjects.pop(), len(branches))
442
387
 
443
388
 
444
- def _nested_guard_findings(
445
- all_nodes: tuple[ast.AST, ...],
446
- path: Path,
447
- code: str,
448
- imports: ImportIndex,
449
- local_classes: frozenset[str],
450
- *,
451
- unsafe_bindings: frozenset[str],
452
- ) -> list[Diagnostic]:
453
- findings: list[Diagnostic] = []
454
- for node in all_nodes:
455
- if (
456
- not isinstance(node, ast.If)
457
- or node.orelse
458
- or not _raises_builtin_type_error(node.body, imports, unsafe_bindings)
459
- or "isinstance" in unsafe_bindings
460
- ):
461
- continue
462
- test = node.test
463
- if (
464
- not isinstance(test, ast.BoolOp)
465
- or not isinstance(test.op, ast.Or)
466
- or len(test.values) != _NESTED_TYPE_CHECKS
467
- ):
468
- continue
469
- first = _negated_isinstance(test.values[0])
470
- second = _negated_isinstance(test.values[1])
471
- if first is None or second is None:
472
- continue
473
- first_subject, first_type = first
474
- second_subject, second_type = second
475
- if not isinstance(first_subject, ast.Name):
476
- continue
477
- attribute = _attribute_path(second_subject, root=first_subject.id)
478
- if (
479
- attribute is None
480
- or not _is_class_pattern_type(first_type, imports, local_classes)
481
- or not _is_class_pattern_type(second_type, imports, local_classes)
482
- ):
483
- continue
484
- findings.append(
485
- Diagnostic(
486
- path=path,
487
- line=node.lineno,
488
- col=node.col_offset + 1,
489
- code=code,
490
- severity=Severity.WARNING,
491
- message=(
492
- f"nested isinstance guard on '{first_subject.id}.{attribute}' — use one match/case class "
493
- "pattern and bind the narrowed attribute consumed after the guard."
494
- ),
495
- )
496
- )
497
- return findings
498
-
499
-
500
389
  def _two_arm_ast_projection_findings(
501
390
  all_nodes: tuple[ast.AST, ...],
502
391
  path: Path,
@@ -602,42 +491,6 @@ def _returns_none(statement: ast.stmt) -> bool:
602
491
  )
603
492
 
604
493
 
605
- def _raises_builtin_type_error(body: list[ast.stmt], imports: ImportIndex, unsafe_bindings: frozenset[str]) -> bool:
606
- if not imports.builtin_is_unshadowed("TypeError") or "TypeError" in unsafe_bindings or not body:
607
- return False
608
- final_statement = body[-1]
609
- if not isinstance(final_statement, ast.Raise) or final_statement.exc is None:
610
- return False
611
- exception = final_statement.exc.func if isinstance(final_statement.exc, ast.Call) else final_statement.exc
612
- return isinstance(exception, ast.Name) and exception.id == "TypeError"
613
-
614
-
615
- def _negated_isinstance(node: ast.expr) -> tuple[ast.expr, ast.expr] | None:
616
- if not isinstance(node, ast.UnaryOp) or not isinstance(node.op, ast.Not):
617
- return None
618
- call = node.operand
619
- if not (
620
- isinstance(call, ast.Call)
621
- and isinstance(call.func, ast.Name)
622
- and call.func.id == "isinstance"
623
- and len(call.args) == _ISINSTANCE_ARGS
624
- and not call.keywords
625
- ):
626
- return None
627
- return call.args[0], call.args[1]
628
-
629
-
630
- def _attribute_path(node: ast.expr, *, root: str) -> str | None:
631
- parts: list[str] = []
632
- current = node
633
- while isinstance(current, ast.Attribute):
634
- parts.append(current.attr)
635
- current = current.value
636
- if not isinstance(current, ast.Name) or current.id != root or not parts:
637
- return None
638
- return ".".join(reversed(parts))
639
-
640
-
641
494
  def _is_class_pattern_type(
642
495
  node: ast.expr,
643
496
  imports: ImportIndex,
@@ -5,7 +5,7 @@ from io import StringIO
5
5
  from itertools import pairwise
6
6
  from pathlib import PurePosixPath
7
7
  import tokenize
8
- from typing import TYPE_CHECKING, ClassVar, NamedTuple, final, override
8
+ from typing import TYPE_CHECKING, ClassVar, Literal, NamedTuple, final, override
9
9
 
10
10
  from sarj_python_lint.rule_base import (
11
11
  AutofixPolicy,
@@ -28,11 +28,13 @@ if TYPE_CHECKING:
28
28
 
29
29
 
30
30
  _MAX_COMBINED_LINE_LENGTH = 120
31
+ type _NameFlow = Literal["ambiguous", "loaded", "none", "rebound", "terminal"]
31
32
 
32
33
 
33
34
  class _WalrusCandidate(NamedTuple):
34
35
  name: str
35
36
  awaited: ast.Await
37
+ is_not_none: bool
36
38
 
37
39
 
38
40
  @final
@@ -40,20 +42,23 @@ class PreferWalrusAwaitedNoneGuard(Rule):
40
42
  id = "prefer-walrus-awaited-none-guard"
41
43
  code = "SARJ432"
42
44
  documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
43
- summary="Bind a compact awaited lookup in its immediately following None guard.",
45
+ summary="Bind a compact awaited lookup in its immediately following terminal None guard.",
44
46
  rationale=(
45
- "When an awaited lookup and its mandatory absence guard are adjacent, binding in the condition keeps "
46
- "the operation and the reason for the temporary name in one compact expression."
47
+ "When an awaited lookup and its terminal None guard are adjacent, binding in the condition keeps "
48
+ "the operation, branch decision, and temporary name in one compact expression."
47
49
  ),
48
50
  remediation=(
49
51
  "Rewrite `value = await lookup(); if value is None: return` as "
50
- "`if (value := await lookup()) is None: return`."
52
+ "`if (value := await lookup()) is None: return`. Preserve `is not None` when the terminal branch "
53
+ "returns or raises with the bound value. Keep two statements when the assignment is an intentional "
54
+ "debugging or tracing boundary."
51
55
  ),
52
56
  category=RuleCategory.STYLE,
53
57
  autofix=AutofixPolicy.NONE,
54
58
  limitations=(
55
- "Only one-line awaited calls followed immediately by an exact `is None` guard containing one return are checked.",
56
- "The combined condition must fit 120 columns; comments, else branches, rebinding, generated files, and broader assignment-expression preferences are excluded.",
59
+ "Only one-line awaited calls followed immediately by an exact `is None` or `is not None` guard containing one return or raise are checked.",
60
+ "A negative guard requires a later use on the surviving path; a positive guard must consume the binding in its terminal statement and leave no later use before rebinding.",
61
+ "The combined condition must fit 120 columns; comments, else branches, ambiguous name flow, generated files, and broader assignment-expression preferences are excluded.",
57
62
  ),
58
63
  examples=(
59
64
  RuleExample(
@@ -99,6 +104,8 @@ class PreferWalrusAwaitedNoneGuard(Rule):
99
104
  def check(self, path: Path, source: str) -> list[Diagnostic]:
100
105
  if path.suffix == ".pyi" or is_generated(path, source):
101
106
  return []
107
+ if any(token not in source for token in ("await", "None", "if", "=")):
108
+ return []
102
109
  tree = parse_or_none(path, source)
103
110
  if tree is None:
104
111
  return []
@@ -110,8 +117,11 @@ class PreferWalrusAwaitedNoneGuard(Rule):
110
117
  candidate = _candidate(assignment, guard, source, source_lines, comment_lines)
111
118
  if candidate is None:
112
119
  continue
113
- name, awaited = candidate
114
- if not _used_before_rebinding(body[index + 2 :], name):
120
+ name, awaited, is_not_none = candidate
121
+ later_flow = _name_flow(body[index + 2 :], name)
122
+ if (is_not_none and later_flow in {"ambiguous", "loaded"}) or (
123
+ not is_not_none and later_flow != "loaded"
124
+ ):
115
125
  continue
116
126
  if is_suppressed(source_lines, assignment.lineno, self.code) or is_suppressed(
117
127
  source_lines, guard.lineno, self.code
@@ -128,8 +138,9 @@ class PreferWalrusAwaitedNoneGuard(Rule):
128
138
  code=self.code,
129
139
  severity=Severity.WARNING,
130
140
  message=(
131
- f"`{name}` only separates an awaited lookup from its None guard; "
132
- f"bind it as `if ({name} := {expression.strip()}) is None:`"
141
+ f"`{name}` only separates an awaited lookup from its terminal None guard; "
142
+ f"bind it as `if ({name} := {expression.strip()}) "
143
+ f"is {'not ' if is_not_none else ''}None:`"
133
144
  ),
134
145
  )
135
146
  )
@@ -157,36 +168,50 @@ def _candidate(
157
168
  and guard.test.end_lineno == guard.lineno
158
169
  and not guard.orelse
159
170
  and len(guard.body) == 1
160
- and isinstance(guard.body[0], ast.Return)
171
+ and isinstance(guard.body[0], (ast.Return, ast.Raise))
161
172
  ):
162
173
  return None
163
174
  name = assignment.targets[0].id
164
- if not _is_none_guard(guard.test, name) or _loads_name(assignment.value, name) or _loads_name(guard.body[0], name):
175
+ is_not_none = _none_guard_kind(guard.test, name)
176
+ if is_not_none is None or _loads_name(assignment.value, name):
177
+ return None
178
+ terminal = guard.body[0]
179
+ usage = _NameUsage(name)
180
+ usage.visit(terminal)
181
+ if usage.rebound or (is_not_none != usage.loaded):
165
182
  return None
166
- if comment_lines.intersection(range(assignment.lineno, guard.lineno + 1)):
183
+ terminal_end = terminal.end_lineno or terminal.lineno
184
+ if comment_lines.intersection(range(assignment.lineno, terminal_end + 1)):
167
185
  return None
168
186
  expression = ast.get_source_segment(source, assignment.value)
169
187
  if expression is None:
170
188
  return None
171
189
  original_line = source_lines[assignment.lineno - 1]
172
190
  indentation = original_line[: len(original_line) - len(original_line.lstrip())].expandtabs(8)
173
- combined = f"{indentation}if ({name} := {expression.strip()}) is None:"
191
+ combined = f"{indentation}if ({name} := {expression.strip()}) is {'not ' if is_not_none else ''}None:"
192
+ if terminal.lineno == guard.lineno:
193
+ terminal_source = ast.get_source_segment(source, terminal)
194
+ if terminal_source is None:
195
+ return None
196
+ combined = f"{combined} {terminal_source.strip()}"
174
197
  if len(combined) > _MAX_COMBINED_LINE_LENGTH:
175
198
  return None
176
- return _WalrusCandidate(name, assignment.value)
199
+ return _WalrusCandidate(name, assignment.value, is_not_none)
177
200
 
178
201
 
179
- def _is_none_guard(test: ast.expr, name: str) -> bool:
180
- return (
202
+ def _none_guard_kind(test: ast.expr, name: str) -> bool | None:
203
+ if not (
181
204
  isinstance(test, ast.Compare)
182
205
  and isinstance(test.left, ast.Name)
183
206
  and test.left.id == name
184
207
  and len(test.ops) == 1
185
- and isinstance(test.ops[0], ast.Is)
208
+ and isinstance(test.ops[0], (ast.Is, ast.IsNot))
186
209
  and len(test.comparators) == 1
187
210
  and isinstance(test.comparators[0], ast.Constant)
188
211
  and test.comparators[0].value is None
189
- )
212
+ ):
213
+ return None
214
+ return isinstance(test.ops[0], ast.IsNot)
190
215
 
191
216
 
192
217
  def _loads_name(node: ast.AST, name: str) -> bool:
@@ -195,15 +220,19 @@ def _loads_name(node: ast.AST, name: str) -> bool:
195
220
  )
196
221
 
197
222
 
198
- def _used_before_rebinding(statements: list[ast.stmt], name: str) -> bool:
223
+ def _name_flow(statements: list[ast.stmt], name: str) -> _NameFlow:
199
224
  for statement in statements:
200
225
  usage = _NameUsage(name)
201
226
  usage.visit(statement)
227
+ if usage.loaded and usage.rebound:
228
+ return "ambiguous"
202
229
  if usage.rebound:
203
- return False
230
+ return "rebound"
204
231
  if usage.loaded:
205
- return True
206
- return False
232
+ return "loaded"
233
+ if isinstance(statement, (ast.Break, ast.Continue, ast.Raise, ast.Return)):
234
+ return "terminal"
235
+ return "none"
207
236
 
208
237
 
209
238
  @final
@@ -234,6 +263,10 @@ class _NameUsage(ast.NodeVisitor):
234
263
  def visit_Lambda(self, node: ast.Lambda) -> None:
235
264
  _ = node
236
265
 
266
+ @override
267
+ def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None:
268
+ _ = node
269
+
237
270
  def visit_Import(self, node: ast.Import) -> None:
238
271
  self.rebound |= any((alias.asname or alias.name.split(".", maxsplit=1)[0]) == self.name for alias in node.names)
239
272