sarj-python-lint 0.87.0__tar.gz → 0.87.2__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 (142) hide show
  1. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/.gitignore +4 -3
  2. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/PKG-INFO +1 -1
  3. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/pyproject.toml +1 -1
  4. sarj_python_lint-0.87.2/src/sarj_python_lint/rules/_ast_position.py +21 -0
  5. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +12 -6
  6. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +3 -2
  7. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +15 -7
  8. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_unused_value_marker.py +68 -8
  9. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +3 -2
  10. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +14 -7
  11. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +13 -6
  12. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +3 -2
  13. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +16 -8
  14. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +11 -4
  15. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_str_enum.py +12 -5
  16. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/require_port_for_service.py +26 -20
  17. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +81 -44
  18. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +31 -16
  19. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +15 -7
  20. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/LICENSE +0 -0
  21. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/README.md +0 -0
  22. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/__init__.py +0 -0
  23. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/__main__.py +0 -0
  24. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/_analysis_session.py +0 -0
  25. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/_filesystem.py +0 -0
  26. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/_python_target.py +0 -0
  27. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  28. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/_secret_names.py +0 -0
  29. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/_version.py +0 -0
  30. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/py.typed +0 -0
  31. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/ratchet.py +0 -0
  32. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rule_base.py +0 -0
  33. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/__init__.py +0 -0
  34. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
  35. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  36. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/_comments.py +0 -0
  37. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  38. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  39. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/_first_party.py +0 -0
  40. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  41. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/_imports.py +0 -0
  42. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/_logging.py +0 -0
  43. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/_paths.py +0 -0
  44. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/_project_index.py +0 -0
  45. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  46. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/_pytest.py +0 -0
  47. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/_registry.py +0 -0
  48. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/_sql.py +0 -0
  49. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  50. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  51. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  52. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  53. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  54. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  55. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  56. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  57. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
  58. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  59. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  60. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  61. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
  62. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  63. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  64. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
  65. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  66. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  67. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  68. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  69. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
  70. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  71. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  72. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  73. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  74. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  75. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  76. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  77. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  78. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  79. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
  80. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  81. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  82. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  83. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  84. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  85. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  86. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  87. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  88. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  89. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  90. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  91. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  92. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  93. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  94. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  95. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  96. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  97. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  98. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  99. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  100. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  101. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  102. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  103. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  104. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  105. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  106. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  107. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  108. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  109. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  110. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  111. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  112. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  113. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  114. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  115. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
  116. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  117. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  118. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  119. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  120. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  121. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  122. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  123. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  124. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  125. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  126. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  127. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  128. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  129. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  130. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  131. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  132. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  133. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
  134. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/require_typed_http_test_response.py +0 -0
  135. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  136. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/stepdown.py +0 -0
  137. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  138. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  139. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  140. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  141. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  142. {sarj_python_lint-0.87.0 → sarj_python_lint-0.87.2}/src/sarj_python_lint/rules/unused_test_factory_option.py +0 -0
@@ -32,9 +32,10 @@ Thumbs.db
32
32
  .zed/
33
33
  *.swp
34
34
 
35
- # Claude Code local state (shared settings.json is tracked)
36
- .claude/*
37
- !.claude/settings.json
35
+ .claude/
36
+ apps/docs/.wrangler/
37
+ apps/docs/.dev.vars*
38
+ !apps/docs/.dev.vars.example
38
39
  .worktrees/
39
40
  .sarj-private-refs.toml
40
41
  .sarj-standards-rollout.toml
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sarj-python-lint
3
- Version: 0.87.0
3
+ Version: 0.87.2
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.87.0"
3
+ version = "0.87.2"
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,21 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
5
+
6
+
7
+ if TYPE_CHECKING:
8
+ import ast
9
+
10
+
11
+ @dataclass(frozen=True, order=True, slots=True)
12
+ class AstPosition:
13
+ line: int
14
+ column: int
15
+
16
+
17
+ def ast_position(node: ast.AST, *, missing: int) -> AstPosition:
18
+ return AstPosition(
19
+ line=getattr(node, "lineno", missing),
20
+ column=getattr(node, "col_offset", missing),
21
+ )
@@ -674,12 +674,12 @@ def _check_direct_responses(
674
674
  routes: tuple[Route, ...],
675
675
  index: FastapiIndex,
676
676
  ) -> list[_Finding]:
677
- statuses, invalid = _direct_response_statuses(function, index)
677
+ direct = _direct_response_statuses(function, index)
678
678
  findings = [
679
679
  _Finding(node, f"[status] {status} is outside the HTTP status range 100..599.")
680
- for status, node in invalid.items()
680
+ for status, node in direct.invalid.items()
681
681
  ]
682
- if not statuses:
682
+ if not direct.statuses:
683
683
  return findings
684
684
  for route in _operations(routes):
685
685
  responses = _route_response_nodes(route)
@@ -689,7 +689,7 @@ def _check_direct_responses(
689
689
  status for node in responses if isinstance(node, ast.Dict) for status in _response_codes(node, index)
690
690
  }
691
691
  primary = _status_code(route.keywords.get("status_code"), index)
692
- missing = sorted(statuses - documented - ({primary} if primary is not None else set()))
692
+ missing = sorted(direct.statuses - documented - ({primary} if primary is not None else set()))
693
693
  if missing:
694
694
  findings.append(
695
695
  _Finding(
@@ -700,9 +700,15 @@ def _check_direct_responses(
700
700
  return findings
701
701
 
702
702
 
703
+ @dataclass(frozen=True, slots=True)
704
+ class _DirectResponseStatuses:
705
+ statuses: set[int]
706
+ invalid: dict[int, ast.Call]
707
+
708
+
703
709
  def _direct_response_statuses(
704
710
  function: ast.FunctionDef | ast.AsyncFunctionDef, index: FastapiIndex
705
- ) -> tuple[set[int], dict[int, ast.Call]]:
711
+ ) -> _DirectResponseStatuses:
706
712
  statuses: set[int] = set()
707
713
  invalid: dict[int, ast.Call] = {}
708
714
  stack: list[tuple[ast.AST, bool]] = [(node, False) for node in function.body]
@@ -721,7 +727,7 @@ def _direct_response_statuses(
721
727
  else:
722
728
  invalid.setdefault(status, call)
723
729
  stack.extend((child, catches_http_exception) for child in children(current))
724
- return statuses, invalid
730
+ return _DirectResponseStatuses(statuses=statuses, invalid=invalid)
725
731
 
726
732
 
727
733
  def _extend_response_try(
@@ -20,6 +20,7 @@ from sarj_python_lint.rule_base import (
20
20
  is_suppressed,
21
21
  parse_or_none,
22
22
  )
23
+ from sarj_python_lint.rules._ast_position import AstPosition, ast_position
23
24
  from sarj_python_lint.rules._imports import ImportIndex
24
25
  from sarj_python_lint.rules._paths import is_generated, is_test_path, is_test_support_path
25
26
 
@@ -954,8 +955,8 @@ def _target_names(node: ast.expr) -> set[str]:
954
955
  return set()
955
956
 
956
957
 
957
- def _position(node: ast.AST) -> tuple[int, int]:
958
- return getattr(node, "lineno", -1), getattr(node, "col_offset", -1)
958
+ def _position(node: ast.AST) -> AstPosition:
959
+ return ast_position(node, missing=-1)
959
960
 
960
961
 
961
962
  def _scope_nodes(statements: Iterable[ast.stmt], *, stop_rebinding: str | None = None) -> Iterator[ast.AST]:
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import ast
4
+ from dataclasses import dataclass
4
5
  from pathlib import PurePosixPath
5
6
  import re
6
7
  from typing import TYPE_CHECKING, ClassVar, final, override
@@ -26,6 +27,13 @@ if TYPE_CHECKING:
26
27
 
27
28
  _RANDOM_UUID_RE = re.compile(r"\b(?:gen_random_uuid|uuid_generate_v4)\s*\(", re.IGNORECASE)
28
29
 
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class _IdentifierEnd:
33
+ end: int
34
+ after_whitespace: int
35
+
36
+
29
37
  # Require SQL structure so prose that merely names the function stays valid.
30
38
  _SQL_SHAPE_RE = re.compile(
31
39
  r"\b(?:CREATE|ALTER|INSERT|UPDATE|SELECT|DEFAULT|VALUES)\b",
@@ -132,12 +140,12 @@ def _inside_uuidv7_builder(sql: str, position: int) -> bool:
132
140
  index = 0
133
141
  while index < position:
134
142
  if sql[index].isalpha() or sql[index] == "_":
135
- end, after = _identifier_end(sql, index, position)
136
- if after < position and sql[after] == "(":
137
- calls.append(sql[index:end].lower())
138
- index = after + 1
143
+ identifier = _identifier_end(sql, index, position)
144
+ if identifier.after_whitespace < position and sql[identifier.after_whitespace] == "(":
145
+ calls.append(sql[index : identifier.end].lower())
146
+ index = identifier.after_whitespace + 1
139
147
  continue
140
- index = end
148
+ index = identifier.end
141
149
  continue
142
150
  if sql[index] == "(":
143
151
  calls.append(None)
@@ -147,11 +155,11 @@ def _inside_uuidv7_builder(sql: str, position: int) -> bool:
147
155
  return any(call in _UUIDV7_BUILDERS for call in calls)
148
156
 
149
157
 
150
- def _identifier_end(sql: str, index: int, position: int) -> tuple[int, int]:
158
+ def _identifier_end(sql: str, index: int, position: int) -> _IdentifierEnd:
151
159
  end = index + 1
152
160
  while end < position and (sql[end].isalnum() or sql[end] == "_"):
153
161
  end += 1
154
162
  after = end
155
163
  while after < position and sql[after].isspace():
156
164
  after += 1
157
- return end, after
165
+ return _IdentifierEnd(end=end, after_whitespace=after)
@@ -32,7 +32,8 @@ class NoUnusedValueMarker(Rule):
32
32
  summary="Do not use standalone assignments to `_` to discard values.",
33
33
  rationale=(
34
34
  "A standalone `_ = value` assignment adds a binding without clarifying intent. A side-effecting call can "
35
- "be invoked directly, while an unused parameter should be addressed at its declaration."
35
+ "be invoked directly. Pure references to formal parameters are excluded because callback and protocol "
36
+ "signatures may require names that Ruff otherwise reports as unused."
36
37
  ),
37
38
  remediation=(
38
39
  "Invoke calls directly instead of assigning their result to `_`. Remove or rename locally owned unused "
@@ -43,14 +44,15 @@ class NoUnusedValueMarker(Rule):
43
44
  autofix=AutofixPolicy.NONE,
44
45
  limitations=(
45
46
  "Plain and annotated assignments whose standalone target is `_` are reported, including chained assignments.",
47
+ "Pure name or tuple references to the enclosing function's unchanged parameters are excluded.",
46
48
  "Unpacking placeholders, loop targets, wildcard imports, and underscore-prefixed names are excluded.",
47
49
  "Generated and vendored files are excluded.",
48
50
  ),
49
51
  examples=(
50
52
  RuleExample(
51
- example_id="unused-parameter-marker",
52
- title="Do not manufacture a use for interface parameters",
53
- outcome=ExampleOutcome.MATCH,
53
+ example_id="callback-parameter-marker",
54
+ title="A callback keeps its required parameter names",
55
+ outcome=ExampleOutcome.NO_MATCH,
54
56
  files=(
55
57
  ExampleFile.python(
56
58
  "app/adapter.py",
@@ -58,6 +60,15 @@ class NoUnusedValueMarker(Rule):
58
60
  ),
59
61
  ),
60
62
  focus_path=PurePosixPath("app/adapter.py"),
63
+ expected_count=0,
64
+ public=False,
65
+ ),
66
+ RuleExample(
67
+ example_id="discarded-nonparameter-value",
68
+ title="A standalone discard binding is unnecessary",
69
+ outcome=ExampleOutcome.MATCH,
70
+ files=(ExampleFile.python("app/adapter.py", "value = 1\n_ = value\n"),),
71
+ focus_path=PurePosixPath("app/adapter.py"),
61
72
  expected_count=1,
62
73
  public=True,
63
74
  ),
@@ -116,6 +127,18 @@ class NoUnusedValueMarker(Rule):
116
127
  if tree is None:
117
128
  return []
118
129
  source_lines = source.splitlines()
130
+ markers = [
131
+ node
132
+ for node in ast.walk(tree)
133
+ if isinstance(node, (ast.Assign, ast.AnnAssign)) and _is_unused_value_marker(node)
134
+ ]
135
+ if not markers:
136
+ return []
137
+ parents = (
138
+ {child: parent for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent)}
139
+ if any(isinstance(node, ast.Assign) and isinstance(node.value, (ast.Name, ast.Tuple)) for node in markers)
140
+ else {}
141
+ )
119
142
  return [
120
143
  Diagnostic(
121
144
  path=path,
@@ -128,10 +151,8 @@ class NoUnusedValueMarker(Rule):
128
151
  "directly or address unused values at their declaration."
129
152
  ),
130
153
  )
131
- for node in ast.walk(tree)
132
- if isinstance(node, (ast.Assign, ast.AnnAssign))
133
- and _is_unused_value_marker(node)
134
- and not is_suppressed(source_lines, node.lineno, self.code)
154
+ for node in markers
155
+ if not _is_pure_parameter_marker(node, parents) and not is_suppressed(source_lines, node.lineno, self.code)
135
156
  ]
136
157
 
137
158
 
@@ -139,3 +160,42 @@ def _is_unused_value_marker(node: ast.Assign | ast.AnnAssign) -> bool:
139
160
  if isinstance(node, ast.AnnAssign):
140
161
  return node.value is not None and isinstance(node.target, ast.Name) and node.target.id == "_"
141
162
  return any(isinstance(target, ast.Name) and target.id == "_" for target in node.targets)
163
+
164
+
165
+ def _is_pure_parameter_marker(
166
+ node: ast.Assign | ast.AnnAssign,
167
+ parents: dict[ast.AST, ast.AST],
168
+ ) -> bool:
169
+ if not isinstance(node, ast.Assign) or len(node.targets) != 1:
170
+ return False
171
+ if not isinstance(node.targets[0], ast.Name) or node.targets[0].id != "_":
172
+ return False
173
+ names = _marker_reference_names(node.value)
174
+ if names is None:
175
+ return False
176
+ function = _enclosing_function(node, parents)
177
+ if function is None:
178
+ return False
179
+ parameters = {argument.arg for argument in ast.walk(function.args) if isinstance(argument, ast.arg)}
180
+ return names <= parameters and not any(
181
+ isinstance(child, ast.Name) and isinstance(child.ctx, ast.Store) and child.id in names
182
+ for child in ast.walk(function)
183
+ )
184
+
185
+
186
+ def _marker_reference_names(value: ast.expr) -> set[str] | None:
187
+ values = value.elts if isinstance(value, ast.Tuple) else [value]
188
+ if not values or not all(isinstance(item, ast.Name) for item in values):
189
+ return None
190
+ return {item.id for item in values if isinstance(item, ast.Name)}
191
+
192
+
193
+ def _enclosing_function(
194
+ node: ast.AST, parents: dict[ast.AST, ast.AST]
195
+ ) -> ast.FunctionDef | ast.AsyncFunctionDef | None:
196
+ current = node
197
+ while current in parents:
198
+ current = parents[current]
199
+ if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef)):
200
+ return current
201
+ return None
@@ -16,6 +16,7 @@ from sarj_python_lint.rule_base import (
16
16
  Severity,
17
17
  parse_or_none,
18
18
  )
19
+ from sarj_python_lint.rules._ast_position import AstPosition, ast_position
19
20
  from sarj_python_lint.rules._imports import ImportIndex
20
21
  from sarj_python_lint.rules._paths import is_generated, is_test_path
21
22
 
@@ -250,10 +251,10 @@ def _parameter_rebound_before(
250
251
  return any(handle in _bound_targets(node) and _position(node) < use_position for node in nodes)
251
252
 
252
253
 
253
- def _position(node: ast.AST) -> tuple[int, int]:
254
+ def _position(node: ast.AST) -> AstPosition:
254
255
  if isinstance(node, ast.withitem):
255
256
  return _position(node.context_expr)
256
- return (getattr(node, "lineno", 0), getattr(node, "col_offset", 0))
257
+ return ast_position(node, missing=0)
257
258
 
258
259
 
259
260
  def _direct_aliases(nodes: tuple[ast.AST, ...], handles: set[str], parameters: set[str]) -> set[str]:
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import ast
4
+ from dataclasses import dataclass
4
5
  from pathlib import PurePosixPath
5
6
  from typing import TYPE_CHECKING, ClassVar, NamedTuple, final, override
6
7
 
@@ -215,17 +216,23 @@ def _classifier_starts(
215
216
  for statements in _nested_statement_lists(body):
216
217
  index = 0
217
218
  while index < len(statements):
218
- candidates, cursor = _adjacent_bare_ifs(statements, index)
219
- branches = _validated_branches(candidates, subject, imports)
220
- if branches is not None and _is_classifier(branches, statements, cursor):
219
+ adjacent = _adjacent_bare_ifs(statements, index)
220
+ branches = _validated_branches(adjacent.candidates, subject, imports)
221
+ if branches is not None and _is_classifier(branches, statements, adjacent.cursor):
221
222
  findings.append(branches[0][0])
222
- index = cursor + 1
223
+ index = adjacent.cursor + 1
223
224
  else:
224
- index = max(cursor, index + 1)
225
+ index = max(adjacent.cursor, index + 1)
225
226
  return tuple(findings)
226
227
 
227
228
 
228
- def _adjacent_bare_ifs(statements: list[ast.stmt], start: int) -> tuple[list[ast.If], int]:
229
+ @dataclass(frozen=True, slots=True)
230
+ class _AdjacentBareIfs:
231
+ candidates: list[ast.If]
232
+ cursor: int
233
+
234
+
235
+ def _adjacent_bare_ifs(statements: list[ast.stmt], start: int) -> _AdjacentBareIfs:
229
236
  cursor = start
230
237
  candidates: list[ast.If] = []
231
238
  while cursor < len(statements):
@@ -234,7 +241,7 @@ def _adjacent_bare_ifs(statements: list[ast.stmt], start: int) -> tuple[list[ast
234
241
  break
235
242
  candidates.append(candidate)
236
243
  cursor += 1
237
- return candidates, cursor
244
+ return _AdjacentBareIfs(candidates=candidates, cursor=cursor)
238
245
 
239
246
 
240
247
  def _validated_branches(
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import ast
4
4
  from collections import Counter
5
+ from dataclasses import dataclass
5
6
  from pathlib import PurePosixPath
6
7
  from typing import TYPE_CHECKING, ClassVar, final, override
7
8
 
@@ -164,12 +165,12 @@ class PreferMatchValueDispatch(Rule):
164
165
  for node in ast.walk(tree):
165
166
  if not isinstance(node, ast.If) or id(node) in continuations:
166
167
  continue
167
- branches, current = _elif_chain(node, lines, continuations)
168
- if len(branches) < _MIN_TESTS or not current.orelse:
168
+ chain = _elif_chain(node, lines, continuations)
169
+ if len(chain.branches) < _MIN_TESTS or not chain.last.orelse:
169
170
  continue
170
- subject = _dispatch_subject(branches, constants)
171
+ subject = _dispatch_subject(chain.branches, constants)
171
172
  if subject is None:
172
- subject = _expanded_dispatch_subject(branches, constants)
173
+ subject = _expanded_dispatch_subject(chain.branches, constants)
173
174
  if subject is not None:
174
175
  candidates.append((node, subject))
175
176
  for branches in _terminal_sibling_dispatches(tree):
@@ -368,7 +369,13 @@ def _subject(expression: ast.expr) -> str | None:
368
369
  return None
369
370
 
370
371
 
371
- def _elif_chain(node: ast.If, lines: list[str], continuations: set[int]) -> tuple[list[ast.If], ast.If]:
372
+ @dataclass(frozen=True, slots=True)
373
+ class _ElifChain:
374
+ branches: list[ast.If]
375
+ last: ast.If
376
+
377
+
378
+ def _elif_chain(node: ast.If, lines: list[str], continuations: set[int]) -> _ElifChain:
372
379
  branches = [node]
373
380
  current = node
374
381
  while len(current.orelse) == 1:
@@ -378,7 +385,7 @@ def _elif_chain(node: ast.If, lines: list[str], continuations: set[int]) -> tupl
378
385
  continuations.add(id(child))
379
386
  branches.append(child)
380
387
  current = child
381
- return branches, current
388
+ return _ElifChain(branches=branches, last=current)
382
389
 
383
390
 
384
391
  def _collect_terminal_siblings(field: list[ast.stmt], candidates: list[list[ast.If]]) -> None:
@@ -16,6 +16,7 @@ from sarj_python_lint.rule_base import (
16
16
  Severity,
17
17
  parse_or_none,
18
18
  )
19
+ from sarj_python_lint.rules._ast_position import AstPosition, ast_position
19
20
  from sarj_python_lint.rules._imports import ImportIndex
20
21
  from sarj_python_lint.rules._paths import is_generated, is_test_path
21
22
 
@@ -294,8 +295,8 @@ def _import_root_rebound_before(
294
295
  )
295
296
 
296
297
 
297
- def _position(node: ast.AST) -> tuple[int, int]:
298
- return (getattr(node, "lineno", 0), getattr(node, "col_offset", 0))
298
+ def _position(node: ast.AST) -> AstPosition:
299
+ return ast_position(node, missing=0)
299
300
 
300
301
 
301
302
  def _restored_mutations(
@@ -377,9 +377,11 @@ def _collect_type_aliases(
377
377
  aliases: dict[str, ast.expr],
378
378
  ) -> None:
379
379
  for statement in tree.body:
380
- target, value = _alias_assignment(statement)
381
- if target is None or value is None:
380
+ assignment = _alias_assignment(statement)
381
+ if assignment is None:
382
382
  continue
383
+ target = assignment.target
384
+ value = assignment.value
383
385
  if _is_new_type_call(value, imports):
384
386
  if len(value.args) >= _MIN_SWAPPABLE_ROLES:
385
387
  carrier = _carrier(value.args[_SECOND_ARGUMENT], imports, _TypeFacts(raw_aliases, nominal_aliases))
@@ -389,14 +391,20 @@ def _collect_type_aliases(
389
391
  aliases[target] = _type_alias_type_value(value, imports) or value
390
392
 
391
393
 
392
- def _alias_assignment(statement: ast.stmt) -> tuple[str | None, ast.expr | None]:
394
+ @dataclass(frozen=True, slots=True)
395
+ class _AliasAssignment:
396
+ target: str
397
+ value: ast.expr
398
+
399
+
400
+ def _alias_assignment(statement: ast.stmt) -> _AliasAssignment | None:
393
401
  if isinstance(statement, ast.TypeAlias):
394
- return statement.name.id, statement.value
402
+ return _AliasAssignment(target=statement.name.id, value=statement.value)
395
403
  if isinstance(statement, ast.Assign) and len(statement.targets) == 1 and isinstance(statement.targets[0], ast.Name):
396
- return statement.targets[0].id, statement.value
397
- if isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name):
398
- return statement.target.id, statement.value
399
- return None, None
404
+ return _AliasAssignment(target=statement.targets[0].id, value=statement.value)
405
+ if isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name) and statement.value is not None:
406
+ return _AliasAssignment(target=statement.target.id, value=statement.value)
407
+ return None
400
408
 
401
409
 
402
410
  def _is_new_type_call(value: ast.expr, imports: ImportIndex) -> TypeGuard[ast.Call]:
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import ast
4
+ from dataclasses import dataclass
4
5
  from enum import StrEnum
5
6
  from pathlib import PurePosixPath
6
7
  from typing import TYPE_CHECKING, ClassVar, override
@@ -237,8 +238,8 @@ class PreferSelfTypeAnnotation(Rule):
237
238
  if method_kind is None or method_kind is _MethodKind.STATIC:
238
239
  return
239
240
  receiver = _first_positional_parameter(node)
240
- bindings, is_generator = _method_scope_facts(node)
241
- if receiver is None or receiver in bindings or is_generator:
241
+ scope = _method_scope_facts(node)
242
+ if receiver is None or receiver in scope.bindings or scope.is_generator:
242
243
  return
243
244
  returns = node.returns
244
245
  if returns is None:
@@ -337,7 +338,13 @@ def _matched_enclosing_class_annotation(node: ast.expr, class_name: str) -> str
337
338
  return None
338
339
 
339
340
 
340
- def _method_scope_facts(node: ast.FunctionDef | ast.AsyncFunctionDef) -> tuple[frozenset[str], bool]:
341
+ @dataclass(frozen=True, slots=True)
342
+ class _MethodScopeFacts:
343
+ bindings: frozenset[str]
344
+ is_generator: bool
345
+
346
+
347
+ def _method_scope_facts(node: ast.FunctionDef | ast.AsyncFunctionDef) -> _MethodScopeFacts:
341
348
  bindings: set[str] = set()
342
349
  has_yield = False
343
350
 
@@ -409,7 +416,7 @@ def _method_scope_facts(node: ast.FunctionDef | ast.AsyncFunctionDef) -> tuple[f
409
416
  visitor = Visitor()
410
417
  for statement in node.body:
411
418
  visitor.visit(statement)
412
- return frozenset(bindings), has_yield
419
+ return _MethodScopeFacts(bindings=frozenset(bindings), is_generator=has_yield)
413
420
 
414
421
 
415
422
  def _is_final_class(node: ast.ClassDef, imports: ImportIndex) -> bool:
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import ast
4
+ from dataclasses import dataclass
4
5
  from pathlib import PurePosixPath
5
6
  import re
6
7
  from types import MappingProxyType
@@ -499,9 +500,9 @@ class PreferStrEnum(Rule):
499
500
  choice_groups = _class_choice_groups(cls)
500
501
  candidates = _class_choice_fields(cls, raw_string_aliases, imports)
501
502
  for stmt, name, default in candidates:
502
- associated_values, generic_values = _choice_values_for_field(choice_groups, name)
503
- if not associated_values and not (
504
- default is not None and len(candidates) == 1 and default in generic_values
503
+ choices = _choice_values_for_field(choice_groups, name)
504
+ if not choices.associated and not (
505
+ default is not None and len(candidates) == 1 and default in choices.generic
505
506
  ):
506
507
  continue
507
508
  diags.append(
@@ -1429,14 +1430,20 @@ def _match_pattern_literals(pattern: ast.pattern) -> list[str]:
1429
1430
  return []
1430
1431
 
1431
1432
 
1432
- def _choice_values_for_field(choice_groups: list[tuple[str, set[str]]], name: str) -> tuple[set[str], set[str]]:
1433
+ @dataclass(frozen=True, slots=True)
1434
+ class _ChoiceValues:
1435
+ associated: set[str]
1436
+ generic: set[str]
1437
+
1438
+
1439
+ def _choice_values_for_field(choice_groups: list[tuple[str, set[str]]], name: str) -> _ChoiceValues:
1433
1440
  associated_values = {
1434
1441
  value for binding, values in choice_groups if _choice_binding_field(binding) == name.lower() for value in values
1435
1442
  }
1436
1443
  generic_values = {
1437
1444
  value for binding, values in choice_groups if _choice_binding_field(binding) is None for value in values
1438
1445
  }
1439
- return associated_values, generic_values
1446
+ return _ChoiceValues(associated=associated_values, generic=generic_values)
1440
1447
 
1441
1448
 
1442
1449
  def _choice_binding_field(binding: str) -> str | None:
@@ -703,29 +703,35 @@ def _annotation_allows_none(annotation: ast.expr | None) -> bool:
703
703
  return isinstance(annotation, ast.Subscript) and _subscript_allows_none(annotation)
704
704
 
705
705
 
706
- def _statements_invoke_field(statements: list[ast.stmt], fields: frozenset[str]) -> tuple[bool, bool]:
706
+ @dataclass(frozen=True, slots=True)
707
+ class _InvocationFlow:
708
+ found: bool
709
+ falls_through: bool
710
+
711
+
712
+ def _statements_invoke_field(statements: list[ast.stmt], fields: frozenset[str]) -> _InvocationFlow:
707
713
  for statement in statements:
708
714
  if isinstance(statement, ast.If):
709
- found, falls_through = _conditional_invokes_field(statement, fields)
710
- if found or not falls_through:
711
- return found, falls_through
715
+ flow = _conditional_invokes_field(statement, fields)
716
+ if flow.found or not flow.falls_through:
717
+ return flow
712
718
  continue
713
719
  if (
714
720
  isinstance(statement, ast.While)
715
721
  and isinstance(statement.test, ast.Constant)
716
722
  and not bool(statement.test.value)
717
723
  ):
718
- found, falls_through = _statements_invoke_field(statement.orelse, fields)
719
- if found or not falls_through:
720
- return found, falls_through
724
+ flow = _statements_invoke_field(statement.orelse, fields)
725
+ if flow.found or not flow.falls_through:
726
+ return flow
721
727
  continue
722
728
  if isinstance(statement, (ast.While, *_UNSUPPORTED_COMPOUND_STATEMENTS)):
723
729
  continue
724
730
  if _node_invokes_field(statement, fields):
725
- return True, True
731
+ return _InvocationFlow(found=True, falls_through=True)
726
732
  if isinstance(statement, ast.Return | ast.Raise):
727
- return False, False
728
- return False, True
733
+ return _InvocationFlow(found=False, falls_through=False)
734
+ return _InvocationFlow(found=False, falls_through=True)
729
735
 
730
736
 
731
737
  def _node_invokes_field(node: ast.AST, fields: frozenset[str]) -> bool:
@@ -881,8 +887,7 @@ def _method_invokes_field(
881
887
  ) -> bool:
882
888
  if _has_unsupported_control_flow(method.body):
883
889
  return False
884
- found, _falls_through = _statements_invoke_field(method.body, fields)
885
- return found
890
+ return _statements_invoke_field(method.body, fields).found
886
891
 
887
892
 
888
893
  def _has_unsupported_control_flow(statements: list[ast.stmt]) -> bool:
@@ -945,17 +950,18 @@ def _constant_annotation_allows_none(annotation: ast.Constant) -> bool:
945
950
  return False
946
951
 
947
952
 
948
- def _conditional_invokes_field(statement: ast.If, fields: frozenset[str]) -> tuple[bool, bool]:
953
+ def _conditional_invokes_field(statement: ast.If, fields: frozenset[str]) -> _InvocationFlow:
949
954
  if _node_invokes_field(statement.test, fields):
950
- return True, True
955
+ return _InvocationFlow(found=True, falls_through=True)
951
956
  if isinstance(statement.test, ast.Constant):
952
957
  branch = statement.body if bool(statement.test.value) else statement.orelse
953
- found, falls_through = _statements_invoke_field(branch, fields)
954
- else:
955
- body_found, body_falls = _statements_invoke_field(statement.body, fields)
956
- else_found, else_falls = _statements_invoke_field(statement.orelse, fields)
957
- found, falls_through = body_found or else_found, body_falls or else_falls
958
- return found, falls_through
958
+ return _statements_invoke_field(branch, fields)
959
+ body = _statements_invoke_field(statement.body, fields)
960
+ otherwise = _statements_invoke_field(statement.orelse, fields)
961
+ return _InvocationFlow(
962
+ found=body.found or otherwise.found,
963
+ falls_through=body.falls_through or otherwise.falls_through,
964
+ )
959
965
 
960
966
 
961
967
  def _boolean_invokes_field(node: ast.BoolOp, fields: frozenset[str]) -> bool: