sarj-python-lint 1.27.5__tar.gz → 1.28.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 (192) hide show
  1. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/pyproject.toml +1 -1
  3. sarj_python_lint-1.28.0/src/sarj_python_lint/rules/_closed_helpers.py +100 -0
  4. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_registry.py +9 -0
  5. sarj_python_lint-1.28.0/src/sarj_python_lint/rules/duplicate_adjacent_exception_handler.py +213 -0
  6. sarj_python_lint-1.28.0/src/sarj_python_lint/rules/duplicate_closed_pure_helper.py +260 -0
  7. sarj_python_lint-1.28.0/src/sarj_python_lint/rules/redundant_test_constructor_forwarder.py +284 -0
  8. sarj_python_lint-1.28.0/src/sarj_python_lint/rules/repeated_unimplemented_test_contract.py +383 -0
  9. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/unused_test_factory_option.py +68 -9
  10. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/.gitignore +0 -0
  11. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/LICENSE +0 -0
  12. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/README.md +0 -0
  13. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/__init__.py +0 -0
  14. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/__main__.py +0 -0
  15. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/_analysis_session.py +0 -0
  16. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/_file_context.py +0 -0
  17. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/_filesystem.py +0 -0
  18. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/_python_target.py +0 -0
  19. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  20. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/_secret_names.py +0 -0
  21. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/_source.py +0 -0
  22. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/_version.py +0 -0
  23. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/benchmark.py +0 -0
  24. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/interpreter_argv.py +0 -0
  25. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/json_boundary.py +0 -0
  26. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/py.typed +0 -0
  27. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/ratchet.py +0 -0
  28. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rule_base.py +0 -0
  29. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  30. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
  31. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  32. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_ast_position.py +0 -0
  33. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  34. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_contract_annotation.py +0 -0
  35. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_copy_on_write.py +0 -0
  36. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_dict_key_views.py +0 -0
  37. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  38. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  39. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  40. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  41. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  42. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_local_source.py +0 -0
  43. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  44. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_nominal_project.py +0 -0
  45. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  46. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
  47. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  48. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  49. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_resource_provenance.py +0 -0
  50. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_set_fields.py +0 -0
  51. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  52. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  53. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  54. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_test_composition.py +0 -0
  55. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_test_provenance.py +0 -0
  56. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/async_cleanup_registered_synchronously.py +0 -0
  57. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +0 -0
  58. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  59. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  60. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  61. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  62. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  63. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  64. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
  65. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  66. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  67. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  68. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  69. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
  70. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  71. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  72. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
  73. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_before_validator_input_mutation.py +0 -0
  74. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_broad_keyword_capture.py +0 -0
  75. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  76. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  77. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  78. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  79. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_database_programmability.py +0 -0
  80. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
  81. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  82. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  83. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  84. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  85. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  86. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  87. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
  88. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_fixed_sleep_in_loop.py +0 -0
  89. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  90. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  91. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  92. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_injected_module_loader.py +0 -0
  93. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_input_model_mutation.py +0 -0
  94. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_interpreter_source_arguments.py +0 -0
  95. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
  96. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_mocked_pydantic_value_object.py +0 -0
  97. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  98. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_nullable_dependency_fallback.py +0 -0
  99. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  100. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  101. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  102. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  103. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  104. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  105. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  106. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  107. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  108. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  109. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  110. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  111. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  112. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  113. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_shallow_container_type_guard.py +0 -0
  114. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  115. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
  116. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  117. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_test_method_grafting.py +0 -0
  118. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  119. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  120. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  121. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_unused_copy_result.py +0 -0
  122. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_unused_underscored_keyword_parameter.py +0 -0
  123. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
  124. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  125. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  126. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  127. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  128. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  129. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_autospec_for_callable_mock.py +0 -0
  130. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  131. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  132. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  133. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  134. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  135. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
  136. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  137. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  138. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
  139. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  140. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  141. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  142. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
  143. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_monotonic_for_elapsed_time.py +0 -0
  144. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  145. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_native_string_check.py +0 -0
  146. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  147. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  148. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  149. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
  150. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_pytest_fixture_injection.py +0 -0
  151. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_regex_fullmatch.py +0 -0
  152. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_required_constructor_parameters.py +0 -0
  153. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  154. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  155. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  156. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  157. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  158. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  159. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  160. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  161. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  162. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  163. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  164. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  165. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  166. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  167. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  168. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  169. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
  170. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_explicit_contract_implementation.py +0 -0
  171. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_explicit_psycopg_transaction.py +0 -0
  172. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_explicit_service_contract.py +0 -0
  173. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_injectable_retry_sleep.py +0 -0
  174. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  175. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  176. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  177. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_precise_factory_signature.py +0 -0
  178. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_public_dependency_contract.py +0 -0
  179. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  180. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
  181. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  182. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  183. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/shared_mutable_pydantic_factory.py +0 -0
  184. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  185. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  186. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  187. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/subprocess_kill_without_reap.py +0 -0
  188. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  189. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  190. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  191. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  192. {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/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: 1.27.5
3
+ Version: 1.28.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 = "1.27.5"
3
+ version = "1.28.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" }]
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from typing import TYPE_CHECKING
5
+
6
+
7
+ if TYPE_CHECKING:
8
+ from sarj_python_lint._file_context import PythonFileContext
9
+ from sarj_python_lint.rules._resource_provenance import ResourceProvenance
10
+
11
+
12
+ _DYNAMIC_NAMES = frozenset(
13
+ {"globals", "locals", "vars", "eval", "exec", "getattr", "setattr", "delattr", "__import__", "__all__"}
14
+ )
15
+
16
+
17
+ def dynamic_namespace(context: PythonFileContext) -> bool:
18
+ return (
19
+ any(node.id in _DYNAMIC_NAMES for node in context.nodes(ast.Name))
20
+ or any(node.attr in _DYNAMIC_NAMES | {"__dict__"} for node in context.nodes(ast.Attribute))
21
+ or any(node.name == "*" for node in context.nodes(ast.alias))
22
+ )
23
+
24
+
25
+ def executable_body(function: ast.FunctionDef) -> list[ast.stmt]:
26
+ body = function.body
27
+ return (
28
+ body[1:]
29
+ if body
30
+ and isinstance(body[0], ast.Expr)
31
+ and isinstance(body[0].value, ast.Constant)
32
+ and isinstance(body[0].value.value, str)
33
+ else body
34
+ )
35
+
36
+
37
+ def closed_references(
38
+ context: PythonFileContext, function: ast.FunctionDef, provenance: ResourceProvenance
39
+ ) -> list[ast.Name] | None:
40
+ if (
41
+ context.parents.get(function) is not context.tree
42
+ or not function.name.startswith("_")
43
+ or function.name.startswith("__")
44
+ or function.decorator_list
45
+ or function.type_params
46
+ ):
47
+ return None
48
+ if (
49
+ function.args.vararg is not None
50
+ or function.args.kwarg is not None
51
+ or dynamic_namespace(context)
52
+ or any(node.attr == function.name for node in context.nodes(ast.Attribute))
53
+ or any(
54
+ isinstance(node.value, str) and function.name in node.value.split(".")
55
+ for node in context.nodes(ast.Constant)
56
+ )
57
+ ):
58
+ return None
59
+ references = [node for node in context.nodes(ast.Name) if node.id == function.name]
60
+ if not references or any(
61
+ not isinstance(node.ctx, ast.Load) or provenance.binding(node.id, node) is not function for node in references
62
+ ):
63
+ return None
64
+ return references
65
+
66
+
67
+ def direct_call(reference: ast.Name, context: PythonFileContext) -> ast.Call | None:
68
+ parent = context.parents.get(reference)
69
+ if not isinstance(parent, ast.Call) or parent.func is not reference:
70
+ return None
71
+ if any(isinstance(argument, ast.Starred) for argument in parent.args) or any(
72
+ keyword.arg is None for keyword in parent.keywords
73
+ ):
74
+ return None
75
+ return parent
76
+
77
+
78
+ def call_matches(function: ast.FunctionDef, call: ast.Call) -> bool:
79
+ positional = [*function.args.posonlyargs, *function.args.args]
80
+ keywords = {arg.arg for arg in (*function.args.args, *function.args.kwonlyargs)}
81
+ required = {arg.arg for arg in positional[: len(positional) - len(function.args.defaults)]}
82
+ required.update(
83
+ arg.arg
84
+ for arg, default in zip(function.args.kwonlyargs, function.args.kw_defaults, strict=True)
85
+ if default is None
86
+ )
87
+ supplied = {arg.arg for arg in positional[: len(call.args)]}
88
+ if len(call.args) > len(positional):
89
+ return False
90
+ for keyword in call.keywords:
91
+ if keyword.arg not in keywords or keyword.arg in supplied:
92
+ return False
93
+ supplied.add(keyword.arg)
94
+ return required <= supplied
95
+
96
+
97
+ def stable_class(context: PythonFileContext, declaration: ast.ClassDef, provenance: ResourceProvenance) -> bool:
98
+ if declaration.decorator_list or declaration.keywords or context.parents.get(declaration) is not context.tree:
99
+ return False
100
+ return provenance.bindings.get(context.tree, {}).get(declaration.name) == [declaration]
@@ -207,6 +207,11 @@ if TYPE_CHECKING:
207
207
  from collections.abc import Mapping
208
208
 
209
209
  from sarj_python_lint.rule_base import Rule
210
+ from sarj_python_lint.rules.duplicate_adjacent_exception_handler import DuplicateAdjacentExceptionHandler
211
+ from sarj_python_lint.rules.duplicate_closed_pure_helper import DuplicateClosedPureHelper
212
+ from sarj_python_lint.rules.redundant_test_constructor_forwarder import RedundantTestConstructorForwarder
213
+ from sarj_python_lint.rules.repeated_unimplemented_test_contract import RepeatedUnimplementedTestContract
214
+
210
215
 
211
216
  REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
212
217
  {
@@ -348,5 +353,9 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
348
353
  PreferRegexFullmatch.id: PreferRegexFullmatch,
349
354
  PreferMonotonicForElapsedTime.id: PreferMonotonicForElapsedTime,
350
355
  TypedErrorReasons.id: TypedErrorReasons,
356
+ RedundantTestConstructorForwarder.id: RedundantTestConstructorForwarder,
357
+ DuplicateAdjacentExceptionHandler.id: DuplicateAdjacentExceptionHandler,
358
+ DuplicateClosedPureHelper.id: DuplicateClosedPureHelper,
359
+ RepeatedUnimplementedTestContract.id: RepeatedUnimplementedTestContract,
351
360
  }
352
361
  )
@@ -0,0 +1,213 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import builtins
5
+ from pathlib import PurePosixPath
6
+ from typing import (
7
+ TYPE_CHECKING,
8
+ ClassVar,
9
+ cast, # ruff: ignore[banned-api] -- vars exposes Any; the object view permits isinstance narrowing.
10
+ final,
11
+ override,
12
+ )
13
+
14
+ from sarj_python_lint.rule_base import (
15
+ AutofixPolicy,
16
+ Diagnostic,
17
+ ExampleFile,
18
+ ExampleOutcome,
19
+ ProjectRule,
20
+ RuleCategory,
21
+ RuleDocumentation,
22
+ RuleExample,
23
+ Severity,
24
+ is_suppressed,
25
+ )
26
+ from sarj_python_lint.rules._closed_helpers import dynamic_namespace, stable_class
27
+ from sarj_python_lint.rules._paths import is_generated
28
+ from sarj_python_lint.rules._project_index import ProjectIndexSet
29
+ from sarj_python_lint.rules._resource_provenance import ResourceProvenance
30
+ from sarj_python_lint.rules._suppression_comments import scan_comments
31
+
32
+
33
+ if TYPE_CHECKING:
34
+ from collections.abc import Mapping
35
+
36
+ from sarj_python_lint._file_context import PythonFileContext
37
+ from sarj_python_lint.rules._project_index import SourceUnit, SymbolRef
38
+ from sarj_python_lint.rules._suppression_comments import Comment
39
+
40
+
41
+ _BUILTIN_NAMESPACE = cast("Mapping[str, object]", vars(builtins))
42
+ _BUILTIN_EXCEPTIONS = frozenset(
43
+ name for name, value in _BUILTIN_NAMESPACE.items() if isinstance(value, type) and issubclass(value, Exception)
44
+ )
45
+ _BAD = "try:\n action()\nexcept ValueError as error:\n raise RuntimeError('invalid') from error\nexcept TypeError as error:\n raise RuntimeError('invalid') from error\n"
46
+ _GOOD = "try:\n action()\nexcept (ValueError, TypeError) as error:\n raise RuntimeError('invalid') from error\n"
47
+
48
+
49
+ @final
50
+ class DuplicateAdjacentExceptionHandler(ProjectRule):
51
+ id = "duplicate-adjacent-exception-handler"
52
+ code = "SARJ488"
53
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
54
+ default_level=Severity.WARNING,
55
+ summary="Adjacent exception handlers repeat the same executable translation.",
56
+ rationale="Repeating one translation for adjacent statically known exception classes creates extra branches to maintain without different behavior.",
57
+ remediation="Consider one tuple handler for the adjacent exception types. Preserve distinct messages, cleanup, ordering, and intentional independently maintained translations.",
58
+ category=RuleCategory.MAINTAINABILITY,
59
+ autofix=AutofixPolicy.NONE,
60
+ limitations=(
61
+ "Only ordinary try statements with adjacent handlers, identical exception bindings, executable ASTs and comments are compared; exception groups and bare re-raise/pass handlers are excluded.",
62
+ "Handler types must be simple names or tuples of unshadowed builtin or statically resolved first-party Exception subclasses, with unconditional stable declarations/imports.",
63
+ "Dynamic namespace access, unknown external exception classes, generated/vendor sources, different comments, and intervening handlers are excluded. No autofix preserves intentional translation seams.",
64
+ ),
65
+ examples=tuple(
66
+ RuleExample(
67
+ example_id=name,
68
+ title=title,
69
+ outcome=outcome,
70
+ files=(ExampleFile.python("app/service.py", source),),
71
+ focus_path=PurePosixPath("app/service.py"),
72
+ expected_count=count,
73
+ public=True,
74
+ )
75
+ for name, title, outcome, source, count in (
76
+ ("repeated-translation", "Adjacent handlers translate identically", ExampleOutcome.MATCH, _BAD, 1),
77
+ ("one-tuple-handler", "One handler owns the shared translation", ExampleOutcome.NO_MATCH, _GOOD, 0),
78
+ )
79
+ ),
80
+ )
81
+ description = documentation.summary
82
+
83
+ @override
84
+ def check_context(self, context: PythonFileContext) -> list[Diagnostic]:
85
+ tree = context.tree
86
+ if context.generated or tree is None or "except" not in context.symbol_source or dynamic_namespace(context):
87
+ return []
88
+ project = context.session.project or ProjectIndexSet.single(context.path, context.source)
89
+ unit = project.unit_or_source(context.path, context.source, tree)
90
+ provenance = ResourceProvenance(context)
91
+ comments = scan_comments(context.source)
92
+ findings: list[Diagnostic] = []
93
+ for statement in context.nodes(ast.Try):
94
+ previous: ast.ExceptHandler | None = None
95
+ reported = False
96
+ for handler in statement.handlers:
97
+ safe = _safe_types(handler.type, context, provenance, project, unit, seen=set())
98
+ if previous is not None and safe and _same_translation(previous, handler, comments):
99
+ if not reported and not is_suppressed(context.source_lines, handler.lineno, self.code):
100
+ findings.append(
101
+ Diagnostic(
102
+ path=context.path,
103
+ line=handler.lineno,
104
+ col=handler.col_offset + 1,
105
+ code=self.code,
106
+ severity=Severity.WARNING,
107
+ message="Adjacent exception handlers have identical translations; consider one tuple handler, preserving intentional separate error contracts.",
108
+ )
109
+ )
110
+ reported = True
111
+ else:
112
+ reported = False
113
+ previous = handler if safe else None
114
+ return sorted(findings, key=lambda finding: (finding.line, finding.col))
115
+
116
+
117
+ def _same_translation(first: ast.ExceptHandler, second: ast.ExceptHandler, comments: list[Comment]) -> bool:
118
+ if first.name != second.name or not first.body or first.type is None or second.type is None:
119
+ return False
120
+ if not {node.id for node in ast.walk(first.type) if isinstance(node, ast.Name)}.isdisjoint(
121
+ node.id for node in ast.walk(second.type) if isinstance(node, ast.Name)
122
+ ):
123
+ return False
124
+ if len(first.body) == 1 and (
125
+ isinstance(first.body[0], ast.Pass) or (isinstance(first.body[0], ast.Raise) and first.body[0].exc is None)
126
+ ):
127
+ return False
128
+ if ast.dump(ast.Module(body=first.body, type_ignores=[])) != ast.dump(
129
+ ast.Module(body=second.body, type_ignores=[])
130
+ ):
131
+ return False
132
+ if any((first.end_lineno or first.lineno) < comment.line < second.lineno for comment in comments):
133
+ return False
134
+ return _comments(first, comments) == _comments(second, comments)
135
+
136
+
137
+ def _comments(handler: ast.ExceptHandler, comments: list[Comment]) -> list[str]:
138
+ return [
139
+ comment.body for comment in comments if handler.lineno <= comment.line <= (handler.end_lineno or handler.lineno)
140
+ ]
141
+
142
+
143
+ def _safe_types(
144
+ expression: ast.expr | None,
145
+ context: PythonFileContext,
146
+ provenance: ResourceProvenance,
147
+ project: ProjectIndexSet,
148
+ unit: SourceUnit | None,
149
+ *,
150
+ seen: set[SymbolRef],
151
+ ) -> bool:
152
+ if isinstance(expression, ast.Tuple):
153
+ return bool(expression.elts) and all(
154
+ _safe_types(element, context, provenance, project, unit, seen=seen) for element in expression.elts
155
+ )
156
+ if not isinstance(expression, ast.Name):
157
+ return False
158
+ if (
159
+ expression.id in _BUILTIN_EXCEPTIONS
160
+ and context.imports.builtin_is_unshadowed(expression.id)
161
+ and provenance.binding(expression.id, expression) is None
162
+ ):
163
+ return True
164
+ return _first_party_exception(expression, context, provenance, project, unit, seen=seen)
165
+
166
+
167
+ def _first_party_exception(
168
+ expression: ast.Name,
169
+ context: PythonFileContext,
170
+ provenance: ResourceProvenance,
171
+ project: ProjectIndexSet,
172
+ unit: SourceUnit | None,
173
+ *,
174
+ seen: set[SymbolRef],
175
+ ) -> bool:
176
+ from sarj_python_lint._file_context import PythonFileContext # ruff: ignore[import-outside-top-level] -- defer until the rule registry finishes loading.
177
+
178
+ binding = provenance.binding(expression.id, expression)
179
+ if not isinstance(binding, ast.ClassDef | ast.alias) or unit is None:
180
+ return False
181
+ if isinstance(binding, ast.alias):
182
+ statement = context.parents.get(binding)
183
+ if statement is None or context.parents.get(statement) is not context.tree:
184
+ return False
185
+ summary = project.class_for(unit, expression)
186
+ if (
187
+ summary is None
188
+ or summary.symbol in seen
189
+ or (owner := project.source_unit(summary.symbol.module)) is None
190
+ or owner.tree is None
191
+ or is_generated(owner.path, owner.source)
192
+ ):
193
+ return False
194
+ definition_context = (
195
+ context if owner.path == context.path else PythonFileContext(owner.path, owner.source, context.session)
196
+ )
197
+ definition_provenance = provenance if definition_context is context else ResourceProvenance(definition_context)
198
+ declaration = next(
199
+ (node for node in definition_context.nodes(ast.ClassDef) if node.name == summary.symbol.name), None
200
+ )
201
+ return (
202
+ declaration is not None
203
+ and stable_class(definition_context, declaration, definition_provenance)
204
+ and len(declaration.bases) == 1
205
+ and _safe_types(
206
+ declaration.bases[0],
207
+ definition_context,
208
+ definition_provenance,
209
+ project,
210
+ owner,
211
+ seen=seen | {summary.symbol},
212
+ )
213
+ )
@@ -0,0 +1,260 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import PurePosixPath
5
+ from typing import TYPE_CHECKING, ClassVar, final, override
6
+
7
+ from sarj_python_lint.rule_base import (
8
+ AutofixPolicy,
9
+ Diagnostic,
10
+ ExampleFile,
11
+ ExampleOutcome,
12
+ Rule,
13
+ RuleCategory,
14
+ RuleDocumentation,
15
+ RuleExample,
16
+ Severity,
17
+ is_suppressed,
18
+ )
19
+ from sarj_python_lint.rules._closed_helpers import call_matches, closed_references, direct_call, executable_body
20
+ from sarj_python_lint.rules._resource_provenance import ResourceProvenance
21
+
22
+
23
+ if TYPE_CHECKING:
24
+ from sarj_python_lint._file_context import PythonFileContext
25
+
26
+
27
+ _HELPER = "def _space(match: re.Match[str]) -> str:\n full = match.group(0)\n digits = match.group(1)\n spaced = ' '.join(digits)\n return full.replace(digits, spaced)\n"
28
+ _BAD = (
29
+ "import re\nFIRST = re.compile(r'item (\\d+)')\nSECOND = re.compile(r'code (\\d+)')\n"
30
+ + _HELPER
31
+ + _HELPER.replace("_space", "_spread")
32
+ + "FIRST.sub(_space, text)\nSECOND.sub(_spread, text)\n"
33
+ )
34
+ _GOOD = (
35
+ "import re\nFIRST = re.compile(r'item (\\d+)')\nSECOND = re.compile(r'code (\\d+)')\n"
36
+ + _HELPER
37
+ + "FIRST.sub(_space, text)\nSECOND.sub(_space, text)\n"
38
+ )
39
+ _PATTERN_METHODS = frozenset({"sub", "subn", "match", "fullmatch", "search", "findall", "finditer", "split"})
40
+ _SUB_REQUIRED_ARGUMENTS = 2
41
+
42
+
43
+ @final
44
+ class DuplicateClosedPureHelper(Rule):
45
+ id = "duplicate-closed-pure-helper"
46
+ code = "SARJ489"
47
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
48
+ default_level=Severity.WARNING,
49
+ summary="Closed private helpers repeat one proven native regex/string transformation.",
50
+ rationale="Identical private transformations duplicate maintained behavior even when different caller patterns choose the domains in which they run.",
51
+ remediation="Reuse one existing transformation while keeping each caller's pattern, order and examples. Preserve documented independent versioning or patch contracts explicitly.",
52
+ category=RuleCategory.MAINTAINABILITY,
53
+ autofix=AutofixPolicy.NONE,
54
+ limitations=(
55
+ "Only undecorated private module-level functions in one authored source file with identical signatures and executable ASTs are compared; no cross-file extraction is suggested.",
56
+ "The supported pure grammar is one re.Match[str] parameter, straight-line local assignments, immutable literals, native Match.group, str.join and str.replace, and a str result. Defaults and unknown calls are excluded.",
57
+ "Every reference must be a known direct call or the first replacement argument to Pattern.sub on a stable, unescaped native re.compile binding with a string pattern.",
58
+ "Escapes, aliases, reflection, rebinding, exports, patch strings, decorators and generated/vendor paths are excluded. Distinct domain docstrings alone do not create runtime behavior; retain useful examples at the surviving owner. No autofix.",
59
+ ),
60
+ examples=tuple(
61
+ RuleExample(
62
+ example_id=name,
63
+ title=title,
64
+ outcome=outcome,
65
+ files=(ExampleFile.python("app/formatters.py", source),),
66
+ focus_path=PurePosixPath("app/formatters.py"),
67
+ expected_count=count,
68
+ public=True,
69
+ )
70
+ for name, title, outcome, source, count in (
71
+ (
72
+ "duplicate-native-transformation",
73
+ "Different patterns use identical closed callbacks",
74
+ ExampleOutcome.MATCH,
75
+ _BAD,
76
+ 1,
77
+ ),
78
+ (
79
+ "shared-existing-transformation",
80
+ "Patterns retain their choices and share one transformation",
81
+ ExampleOutcome.NO_MATCH,
82
+ _GOOD,
83
+ 0,
84
+ ),
85
+ )
86
+ ),
87
+ )
88
+ description = documentation.summary
89
+
90
+ @override
91
+ def check_context(self, context: PythonFileContext) -> list[Diagnostic]:
92
+ tree = context.tree
93
+ if context.generated or tree is None or ".group" not in context.symbol_source:
94
+ return []
95
+ provenance = ResourceProvenance(context)
96
+ owners: dict[str, ast.FunctionDef] = {}
97
+ findings: list[Diagnostic] = []
98
+ for function in tree.body:
99
+ if (
100
+ not isinstance(function, ast.FunctionDef)
101
+ or function.returns is None
102
+ or not _pure_body(function, context)
103
+ ):
104
+ continue
105
+ references = closed_references(context, function, provenance)
106
+ if references is None or any(
107
+ not _known_reference(reference, function, context, provenance) for reference in references
108
+ ):
109
+ continue
110
+ key = (
111
+ ast.dump(function.args)
112
+ + ast.dump(function.returns)
113
+ + ast.dump(ast.Module(body=executable_body(function), type_ignores=[]))
114
+ )
115
+ if key not in owners:
116
+ owners[key] = function
117
+ elif not is_suppressed(context.source_lines, function.lineno, self.code):
118
+ findings.append(
119
+ Diagnostic(
120
+ path=context.path,
121
+ line=function.lineno,
122
+ col=function.col_offset + 1,
123
+ code=self.code,
124
+ severity=Severity.WARNING,
125
+ message=f"`{function.name}` repeats the closed native transformation in `{owners[key].name}`; reuse that helper while preserving caller patterns, useful examples, and intentional independent contracts.",
126
+ )
127
+ )
128
+ return findings
129
+
130
+
131
+ def _pure_body(function: ast.FunctionDef, context: PythonFileContext) -> bool:
132
+ arguments = [*function.args.posonlyargs, *function.args.args, *function.args.kwonlyargs]
133
+ if (
134
+ len(arguments) != 1
135
+ or function.args.defaults
136
+ or any(function.args.kw_defaults)
137
+ or not isinstance(function.returns, ast.Name)
138
+ ):
139
+ return False
140
+ if function.returns.id != "str" or not context.imports.builtin_is_unshadowed("str"):
141
+ return False
142
+ annotation = arguments[0].annotation
143
+ if (
144
+ not isinstance(annotation, ast.Subscript)
145
+ or context.imports.resolved_qualified_name(annotation.value) != "re.Match"
146
+ or not isinstance(annotation.slice, ast.Name)
147
+ or annotation.slice.id != "str"
148
+ ):
149
+ return False
150
+ types = {arguments[0].arg: "match"}
151
+ body = executable_body(function)
152
+ if not body or not isinstance(body[-1], ast.Return):
153
+ return False
154
+ for statement in body[:-1]:
155
+ if (
156
+ not isinstance(statement, ast.Assign)
157
+ or len(statement.targets) != 1
158
+ or not isinstance(statement.targets[0], ast.Name)
159
+ ):
160
+ return False
161
+ name = statement.targets[0].id
162
+ kind = _native_type(statement.value, types)
163
+ if name in types or kind is None:
164
+ return False
165
+ types[name] = kind
166
+ return body[-1].value is not None and _native_type(body[-1].value, types) == "str"
167
+
168
+
169
+ def _native_type(expression: ast.expr, types: dict[str, str]) -> str | None:
170
+ if isinstance(expression, ast.Name):
171
+ return types.get(expression.id)
172
+ if isinstance(expression, ast.Constant):
173
+ return "str" if isinstance(expression.value, str) else "int" if type(expression.value) is int else None
174
+ if not isinstance(expression, ast.Call) or expression.keywords or not isinstance(expression.func, ast.Attribute):
175
+ return None
176
+ receiver = _native_type(expression.func.value, types)
177
+ arguments = [_native_type(argument, types) for argument in expression.args]
178
+ if receiver == "match" and expression.func.attr == "group" and arguments == ["int"]:
179
+ return "str"
180
+ if receiver == "str" and (
181
+ (expression.func.attr == "join" and arguments == ["str"])
182
+ or (expression.func.attr == "replace" and arguments in (["str", "str"], ["str", "str", "int"]))
183
+ ):
184
+ return "str"
185
+ return None
186
+
187
+
188
+ def _known_reference(
189
+ reference: ast.Name, function: ast.FunctionDef, context: PythonFileContext, provenance: ResourceProvenance
190
+ ) -> bool:
191
+ call = direct_call(reference, context)
192
+ if call is not None:
193
+ return call_matches(function, call)
194
+ parent = context.parents.get(reference)
195
+ if (
196
+ not isinstance(parent, ast.Call)
197
+ or len(parent.args) < _SUB_REQUIRED_ARGUMENTS
198
+ or parent.args[0] is not reference
199
+ or not isinstance(parent.func, ast.Attribute)
200
+ or parent.func.attr != "sub"
201
+ ):
202
+ return False
203
+ return (
204
+ isinstance(parent.func.value, ast.Name)
205
+ and _native_pattern(parent.func.value, context, provenance)
206
+ and not any(isinstance(argument, ast.Starred) for argument in parent.args)
207
+ and not any(keyword.arg is None for keyword in parent.keywords)
208
+ )
209
+
210
+
211
+ def _native_pattern(receiver: ast.Name, context: PythonFileContext, provenance: ResourceProvenance) -> bool:
212
+ constructor = provenance.constructor(receiver, receiver)
213
+ binding = provenance.binding(receiver.id, receiver)
214
+ if (
215
+ constructor is None
216
+ or binding is None
217
+ or not provenance.imported(constructor.func, "re.compile")
218
+ or not constructor.args
219
+ ):
220
+ return False
221
+ if not _string_pattern(constructor.args[0], provenance, frozenset()) or any(
222
+ isinstance(node.value, str)
223
+ and (receiver.id in node.value.split(".") or node.value in {"re.compile", "re.Match", "re.Pattern"})
224
+ for node in context.nodes(ast.Constant)
225
+ ):
226
+ return False
227
+ declaration = context.parents.get(binding)
228
+ if declaration is None or context.parents.get(declaration) is not context.tree or receiver.id in provenance.mutated:
229
+ return False
230
+ for read in provenance.reads.get(receiver.id, ()):
231
+ attribute = context.parents.get(read)
232
+ use = context.parents.get(attribute) if attribute is not None else None
233
+ if (
234
+ provenance.binding(read.id, read) is not binding
235
+ or not isinstance(attribute, ast.Attribute)
236
+ or attribute.attr not in _PATTERN_METHODS
237
+ or not isinstance(use, ast.Call)
238
+ or use.func is not attribute
239
+ ):
240
+ return False
241
+ return True
242
+
243
+
244
+ def _string_pattern(expression: ast.expr, provenance: ResourceProvenance, seen: frozenset[str]) -> bool:
245
+ if isinstance(expression, ast.Constant):
246
+ return isinstance(expression.value, str)
247
+ if isinstance(expression, ast.BinOp) and isinstance(expression.op, ast.Add):
248
+ return _string_pattern(expression.left, provenance, seen) and _string_pattern(
249
+ expression.right, provenance, seen
250
+ )
251
+ if not isinstance(expression, ast.Name) or expression.id in seen:
252
+ return False
253
+ binding = provenance.binding(expression.id, expression)
254
+ if not isinstance(binding, ast.Name):
255
+ return False
256
+ statement = provenance.context.parents.get(binding)
257
+ if statement is None or provenance.context.parents.get(statement) is not provenance.context.tree:
258
+ return False
259
+ value = provenance.assignment_value(binding, expression)
260
+ return value is not None and _string_pattern(value, provenance, seen | {expression.id})