sarj-python-lint 1.16.3__tar.gz → 1.18.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 (175) hide show
  1. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/__main__.py +12 -0
  4. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_registry.py +2 -2
  5. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +136 -19
  6. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +110 -21
  7. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +44 -5
  8. sarj_python_lint-1.18.0/src/sarj_python_lint/rules/prefer_required_constructor_parameters.py +367 -0
  9. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/require_public_dependency_contract.py +19 -2
  10. sarj_python_lint-1.16.3/src/sarj_python_lint/rules/discourage_nullable_constructor_parameters.py +0 -171
  11. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/.gitignore +0 -0
  12. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/LICENSE +0 -0
  13. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/README.md +0 -0
  14. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/__init__.py +0 -0
  15. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/_analysis_session.py +0 -0
  16. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/_file_context.py +0 -0
  17. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/_filesystem.py +0 -0
  18. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/_python_target.py +0 -0
  19. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  20. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/_secret_names.py +0 -0
  21. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/_version.py +0 -0
  22. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/benchmark.py +0 -0
  23. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/json_boundary.py +0 -0
  24. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/py.typed +0 -0
  25. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/ratchet.py +0 -0
  26. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rule_base.py +0 -0
  27. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  28. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
  29. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  30. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_ast_position.py +0 -0
  31. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  32. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_contract_annotation.py +0 -0
  33. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_dict_key_views.py +0 -0
  34. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  35. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  36. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  37. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  38. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  39. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_local_source.py +0 -0
  40. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  41. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_nominal_project.py +0 -0
  42. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  43. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
  44. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  45. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  46. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_resource_provenance.py +0 -0
  47. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_set_fields.py +0 -0
  48. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  49. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  50. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  51. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/_test_composition.py +0 -0
  52. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/async_cleanup_registered_synchronously.py +0 -0
  53. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +0 -0
  54. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  55. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  56. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  57. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  58. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  59. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  60. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
  61. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  62. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  63. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  64. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  65. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
  66. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  67. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  68. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
  69. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_broad_keyword_capture.py +0 -0
  70. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  71. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  72. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  73. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  74. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_database_programmability.py +0 -0
  75. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
  76. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  77. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  78. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  79. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  80. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  81. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  82. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
  83. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  84. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  85. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  86. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_injected_module_loader.py +0 -0
  87. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
  88. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  89. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_nullable_dependency_fallback.py +0 -0
  90. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  91. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  92. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  93. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  94. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  95. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  96. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  97. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  98. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  99. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  100. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  101. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  102. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  103. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  104. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_shallow_container_type_guard.py +0 -0
  105. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  106. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
  107. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  108. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  109. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  110. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  111. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_unused_underscored_keyword_parameter.py +0 -0
  112. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
  113. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  114. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  115. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  116. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  117. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  118. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_autospec_for_callable_mock.py +0 -0
  119. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  120. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  121. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  122. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  123. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
  124. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  125. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  126. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
  127. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  128. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
  129. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_monotonic_for_elapsed_time.py +0 -0
  130. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  131. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  132. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  133. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  134. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
  135. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_pytest_fixture_injection.py +0 -0
  136. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_regex_fullmatch.py +0 -0
  137. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  138. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  139. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  140. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  141. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  142. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  143. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  144. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  145. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  146. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  147. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  148. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  149. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  150. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  151. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  152. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  153. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
  154. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/require_explicit_contract_implementation.py +0 -0
  155. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/require_explicit_psycopg_transaction.py +0 -0
  156. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/require_explicit_service_contract.py +0 -0
  157. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  158. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  159. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  160. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/require_precise_factory_signature.py +0 -0
  161. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  162. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
  163. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  164. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  165. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/shared_mutable_pydantic_factory.py +0 -0
  166. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  167. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  168. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  169. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/subprocess_kill_without_reap.py +0 -0
  170. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  171. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  172. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  173. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  174. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  175. {sarj_python_lint-1.16.3 → sarj_python_lint-1.18.0}/src/sarj_python_lint/rules/unused_test_factory_option.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sarj-python-lint
3
- Version: 1.16.3
3
+ Version: 1.18.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.16.3"
3
+ version = "1.18.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" }]
@@ -84,7 +84,18 @@ def _python_files(p: Path) -> list[Path]:
84
84
  return sorted(out)
85
85
 
86
86
 
87
+ def _canonical_rule_ids(rule_ids: list[str]) -> list[str]:
88
+ aliases = {
89
+ alias: rule_id
90
+ for rule_id, cls in REGISTRY.items()
91
+ if (documentation := cls.documentation) is not None
92
+ for alias in documentation.aliases
93
+ }
94
+ return list(dict.fromkeys(rid if rid in REGISTRY else aliases.get(rid, rid) for rid in rule_ids))
95
+
96
+
87
97
  def _check(rule_ids: list[str], paths: list[Path]) -> list[Diagnostic]:
98
+ rule_ids = _canonical_rule_ids(rule_ids)
88
99
  unknown = [rid for rid in rule_ids if rid not in REGISTRY]
89
100
  if unknown:
90
101
  sys.stderr.write(f"unknown rule(s): {', '.join(unknown)}\n")
@@ -155,6 +166,7 @@ _DIAGNOSTIC_PRECEDENCE = MappingProxyType(
155
166
  # Keep the annotation diagnostics when SARJ094 is absent, including
156
167
  # when a concrete response_model makes the route contract complete.
157
168
  "SARJ094": frozenset({"SARJ008", "SARJ447"}),
169
+ "SARJ095": frozenset({"SARJ468"}),
158
170
  "SARJ099": frozenset({"SARJ420"}),
159
171
  # Any-valued mappings are the stronger correctness failure. SARJ008
160
172
  # still owns every fixed dictionary return when selected alone, while
@@ -9,7 +9,6 @@ from sarj_python_lint.rules.complex_postgres_query_requires_architecture_review
9
9
  ComplexPostgresQueryRequiresArchitectureReview,
10
10
  )
11
11
  from sarj_python_lint.rules.defect_xfail_requires_explicit_strict import DefectXfailRequiresExplicitStrict
12
- from sarj_python_lint.rules.discourage_nullable_constructor_parameters import DiscourageNullableConstructorParameters
13
12
  from sarj_python_lint.rules.docstring_args_restate_signature import (
14
13
  DocstringArgsRestateSignature,
15
14
  )
@@ -136,6 +135,7 @@ from sarj_python_lint.rules.prefer_or_pattern import PreferOrPattern
136
135
  from sarj_python_lint.rules.prefer_pydantic_json_value import PreferPydanticJsonValue
137
136
  from sarj_python_lint.rules.prefer_pytest_fixture_injection import PreferPytestFixtureInjection
138
137
  from sarj_python_lint.rules.prefer_regex_fullmatch import PreferRegexFullmatch
138
+ from sarj_python_lint.rules.prefer_required_constructor_parameters import PreferRequiredConstructorParameters
139
139
  from sarj_python_lint.rules.prefer_self_documenting_constant import (
140
140
  PreferSelfDocumentingConstant,
141
141
  )
@@ -203,7 +203,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
203
203
  {
204
204
  NoDatabaseProgrammability.id: NoDatabaseProgrammability,
205
205
  RequireExplicitContractImplementation.id: RequireExplicitContractImplementation,
206
- DiscourageNullableConstructorParameters.id: DiscourageNullableConstructorParameters,
206
+ PreferRequiredConstructorParameters.id: PreferRequiredConstructorParameters,
207
207
  RequirePublicDependencyContract.id: RequirePublicDependencyContract,
208
208
  RequirePreciseFactorySignature.id: RequirePreciseFactorySignature,
209
209
  RequireExplicitServiceContract.id: RequireExplicitServiceContract,
@@ -20,10 +20,13 @@ from sarj_python_lint.rules._paths import is_test_path, is_test_support_path
20
20
 
21
21
 
22
22
  if TYPE_CHECKING:
23
+ from collections.abc import Iterator
24
+
23
25
  from sarj_python_lint._file_context import PythonFileContext
24
26
 
25
27
 
26
28
  _CONSTANT_NAME = re.compile(r"^_?[A-Z][A-Z0-9_]*$")
29
+ _TYPING_SOURCES = frozenset({"typing", "typing_extensions"})
27
30
  _MUTATING_METHODS = frozenset(
28
31
  {
29
32
  "add",
@@ -73,21 +76,25 @@ class PreferImmutableModuleConstant(Rule):
73
76
  code: str = "SARJ096"
74
77
  documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
75
78
  default_level=Severity.WARNING,
76
- summary=("Nonempty uppercase module collections allow top-level membership or keys to change at runtime."),
79
+ summary=(
80
+ "Module collection constants should use native immutable containers or declare dictionary bindings as Final."
81
+ ),
77
82
  rationale=(
78
- "A constant-looking collection can expose process-wide top-level mutation even when callers intend it as a "
79
- "read-only lookup table."
83
+ "Tuple and frozenset protect collection membership. Final makes a dictionary's constant binding explicit "
84
+ "without changing its runtime type or concrete dictionary API."
80
85
  ),
81
86
  remediation=(
82
- "When the concrete collection API is not part of the contract, use a tuple for ordered values, a frozenset for "
83
- "membership, or a Mapping-typed immutable mapping for keyed values. Recursively freeze nested values when needed."
87
+ "When the concrete list or set API is not part of the contract, use a tuple for ordered values or a frozenset "
88
+ "for membership. Annotate dictionary constants with Final[dict[K, V]]. Final prevents reassignment in type-checked "
89
+ "code but does not freeze dictionary contents. Use MappingProxyType when runtime write protection is required."
84
90
  ),
85
91
  category=RuleCategory.MAINTAINABILITY,
86
92
  autofix=AutofixPolicy.NONE,
87
93
  limitations=(
88
94
  "Empty collections and collections intentionally mutated or passed to unknown calls are not reported.",
89
- "The suggested replacements prevent top-level membership or key changes; tuple and MappingProxyType are shallow.",
90
- "Changing list or dict to an immutable type can affect consumers, serialization, equality, and concrete API methods.",
95
+ "Direct declarations and declarations in module-level if/try suites are checked; repeated bindings, loops, functions, and class bodies are excluded.",
96
+ "Final has no runtime enforcement and permits item mutation on dictionaries; tuple and frozenset are shallow.",
97
+ "Changing a list or set to an immutable type can affect consumers, serialization, and concrete API methods.",
91
98
  "Test, test-support, and generated files are excluded.",
92
99
  ),
93
100
  examples=(
@@ -122,12 +129,42 @@ class PreferImmutableModuleConstant(Rule):
122
129
  RuleExample(
123
130
  example_id="immutable-module-mapping",
124
131
  scenario="keyed-values",
125
- title="Read-only mapping used for keyed values",
132
+ title="Final dictionary binding used for keyed values",
126
133
  outcome=ExampleOutcome.NO_MATCH,
127
134
  files=(
128
135
  ExampleFile.python(
129
136
  "settings.py",
130
- 'from collections.abc import Mapping\nfrom types import MappingProxyType\n\nROLE_LABELS: Mapping[str, str] = MappingProxyType({"admin": "Administrator"})\n',
137
+ 'from typing import Final\n\nROLE_LABELS: Final[dict[str, str]] = {"admin": "Administrator"}\n',
138
+ ),
139
+ ),
140
+ focus_path=PurePosixPath("settings.py"),
141
+ expected_count=0,
142
+ public=True,
143
+ ),
144
+ RuleExample(
145
+ example_id="conditional-module-mapping",
146
+ scenario="conditional-factory",
147
+ title="Conditional dictionary factory needs constant intent",
148
+ outcome=ExampleOutcome.MATCH,
149
+ files=(
150
+ ExampleFile.python(
151
+ "settings.py",
152
+ 'if voice_enabled:\n TOOL_LABELS = dict.fromkeys(("call", "transfer"), "Voice")\n',
153
+ ),
154
+ ),
155
+ focus_path=PurePosixPath("settings.py"),
156
+ expected_count=1,
157
+ public=True,
158
+ ),
159
+ RuleExample(
160
+ example_id="final-conditional-module-mapping",
161
+ scenario="conditional-factory",
162
+ title="Conditional dictionary factory declares a Final binding",
163
+ outcome=ExampleOutcome.NO_MATCH,
164
+ files=(
165
+ ExampleFile.python(
166
+ "settings.py",
167
+ 'from typing import Final\n\nif voice_enabled:\n TOOL_LABELS: Final[dict[str, str]] = dict.fromkeys(("call", "transfer"), "Voice")\n',
131
168
  ),
132
169
  ),
133
170
  focus_path=PurePosixPath("settings.py"),
@@ -147,13 +184,18 @@ class PreferImmutableModuleConstant(Rule):
147
184
  if tree is None:
148
185
  return []
149
186
  mutated = _mutated_names(tree)
150
- shadowed_builtins = _module_bound_names(tree) & {"dict", "list", "set"}
187
+ module_names = _module_bound_names(tree)
188
+ shadowed_builtins = {"dict", "list", "set"} if "*" in module_names else module_names & {"dict", "list", "set"}
189
+ statements = tuple(_module_statements(tree.body))
190
+ final_names = _final_names(statements, context)
151
191
  findings: list[Diagnostic] = []
152
- for statement in tree.body:
192
+ for statement in statements:
153
193
  for name, value in _bindings(statement):
154
194
  kind = _mutable_literal_kind(value, shadowed_builtins=shadowed_builtins)
155
195
  if kind is None or name in mutated or not _CONSTANT_NAME.fullmatch(name) or name.startswith("__"):
156
196
  continue
197
+ if kind == "dict" and name in final_names:
198
+ continue
157
199
  findings.append(
158
200
  Diagnostic(
159
201
  path,
@@ -167,6 +209,57 @@ class PreferImmutableModuleConstant(Rule):
167
209
  return findings
168
210
 
169
211
 
212
+ def _module_statements(body: list[ast.stmt]) -> Iterator[ast.stmt]:
213
+ for statement in body:
214
+ yield statement
215
+ match statement:
216
+ case ast.If():
217
+ yield from _module_statements(statement.body)
218
+ yield from _module_statements(statement.orelse)
219
+ case ast.Try() | ast.TryStar():
220
+ yield from _module_statements(statement.body)
221
+ for handler in statement.handlers:
222
+ yield from _module_statements(handler.body)
223
+ yield from _module_statements(statement.orelse)
224
+ yield from _module_statements(statement.finalbody)
225
+ case _:
226
+ pass
227
+
228
+
229
+ def _final_names(statements: tuple[ast.stmt, ...], context: PythonFileContext) -> frozenset[str]:
230
+ annotations = [
231
+ statement
232
+ for statement in statements
233
+ if isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name)
234
+ ]
235
+ tree = context.tree
236
+ if not annotations or tree is None:
237
+ return frozenset()
238
+ non_import_bindings = _scope_bound_names(tree.body, include_imports=False)
239
+ return frozenset(
240
+ statement.target.id
241
+ for statement in annotations
242
+ if isinstance(statement.target, ast.Name)
243
+ and _is_final_annotation(statement.annotation, context, non_import_bindings)
244
+ )
245
+
246
+
247
+ def _is_final_annotation(annotation: ast.expr, context: PythonFileContext, non_import_bindings: set[str]) -> bool:
248
+ if isinstance(annotation, ast.Constant) and isinstance(annotation.value, str):
249
+ try:
250
+ annotation = ast.parse(annotation.value.strip(), mode="eval").body
251
+ except SyntaxError:
252
+ return False
253
+ if isinstance(annotation, ast.Subscript):
254
+ if isinstance(annotation.slice, ast.Tuple):
255
+ return False
256
+ annotation = annotation.value
257
+ root = _root_name(annotation)
258
+ return root not in non_import_bindings and context.module_imports.resolves(
259
+ annotation, sources=_TYPING_SOURCES, symbol="Final"
260
+ )
261
+
262
+
170
263
  def _bindings(statement: ast.stmt) -> tuple[tuple[str, ast.expr], ...]:
171
264
  match statement:
172
265
  case ast.Assign(targets=targets, value=value):
@@ -195,14 +288,30 @@ def _mutable_literal_kind(value: ast.expr, *, shadowed_builtins: set[str]) -> st
195
288
  return None
196
289
  case ast.Call(func=ast.Name(id=kind)) if kind in {"set", "dict", "list"} and kind not in shadowed_builtins:
197
290
  return kind
291
+ case ast.Call(func=ast.Attribute(value=ast.Name(id="dict"), attr="fromkeys"), args=args, keywords=[]) if (
292
+ "dict" not in shadowed_builtins
293
+ and len(args) in {1, 2}
294
+ and not any(isinstance(argument, ast.Starred) for argument in args)
295
+ ):
296
+ return None if _empty_literal(args[0]) else "dict"
198
297
  case _:
199
298
  return None
200
299
 
201
300
 
301
+ def _empty_literal(value: ast.expr) -> bool:
302
+ match value:
303
+ case (
304
+ ast.List(elts=[]) | ast.Tuple(elts=[]) | ast.Set(elts=[]) | ast.Dict(keys=[]) | ast.Constant(value="" | b"")
305
+ ):
306
+ return True
307
+ case _:
308
+ return False
309
+
310
+
202
311
  def _mutated_names(tree: ast.Module) -> frozenset[str]:
203
312
  mutated: set[str] = set()
204
313
  module_bindings: dict[str, int] = {}
205
- for statement in tree.body:
314
+ for statement in _module_statements(tree.body):
206
315
  if isinstance(statement, ast.Assign) and len(statement.targets) > 1:
207
316
  # Skip every chained alias because mutation or escape through one name changes them all.
208
317
  mutated.update(target.id for target in statement.targets if isinstance(target, ast.Name))
@@ -424,8 +533,9 @@ def _scope_global_names(body: list[ast.stmt]) -> set[str]:
424
533
 
425
534
 
426
535
  class _BoundNameCollector(ast.NodeVisitor):
427
- def __init__(self) -> None:
536
+ def __init__(self, *, include_imports: bool) -> None:
428
537
  self.names: set[str] = set()
538
+ self._include_imports: bool = include_imports
429
539
 
430
540
  def _record_target(self, target: ast.expr) -> None:
431
541
  match target:
@@ -503,11 +613,13 @@ class _BoundNameCollector(ast.NodeVisitor):
503
613
 
504
614
  @override
505
615
  def visit_Import(self, node: ast.Import) -> None:
506
- self.names.update(alias.asname or alias.name.split(".", maxsplit=1)[0] for alias in node.names)
616
+ if self._include_imports:
617
+ self.names.update(alias.asname or alias.name.split(".", maxsplit=1)[0] for alias in node.names)
507
618
 
508
619
  @override
509
620
  def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
510
- self.names.update(alias.asname or alias.name for alias in node.names)
621
+ if self._include_imports:
622
+ self.names.update(alias.asname or alias.name for alias in node.names)
511
623
 
512
624
  @override
513
625
  def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
@@ -544,8 +656,8 @@ class _BoundNameCollector(ast.NodeVisitor):
544
656
  self.generic_visit(node)
545
657
 
546
658
 
547
- def _scope_bound_names(body: list[ast.stmt]) -> set[str]:
548
- collector = _BoundNameCollector()
659
+ def _scope_bound_names(body: list[ast.stmt], *, include_imports: bool = True) -> set[str]:
660
+ collector = _BoundNameCollector(include_imports=include_imports)
549
661
  for statement in body:
550
662
  collector.visit(statement)
551
663
  return collector.names
@@ -615,10 +727,15 @@ def _literal_container_root_names(value: ast.expr) -> tuple[str, ...]:
615
727
 
616
728
 
617
729
  def _message(name: str, kind: str) -> str:
618
- replacement = {"list": "tuple", "set": "frozenset", "dict": "an immutable mapping"}[kind]
730
+ if kind == "dict":
731
+ return (
732
+ f"uppercase module dictionary `{name}` should declare constant intent with Final[dict[K, V]]; "
733
+ "Final prevents reassignment in type-checked code but does not freeze dictionary contents."
734
+ )
735
+ replacement = {"list": "tuple", "set": "frozenset"}[kind]
619
736
  return (
620
737
  f"uppercase module collection `{name}` is a mutable {kind} — consider {replacement} to prevent top-level "
621
- "membership or key changes; preserve concrete APIs and recursively freeze nested values when required."
738
+ "membership changes; preserve concrete APIs and recursively freeze nested values when required."
622
739
  )
623
740
 
624
741
 
@@ -101,7 +101,8 @@ class PreferMatchTypeDispatch(Rule):
101
101
  limitations=(
102
102
  "General dispatch requires three or more adjacent, unguarded `isinstance` branches over the same simple name. Two arms are checked only for an exact `ast.Name.id` / `ast.Attribute.attr` projection followed by a None-returning fallback.",
103
103
  "A terminating sibling prefix may precede a two-arm if/elif tail whose bodies can fall through. Preserve nested checks inside case bodies rather than moving them into case guards.",
104
- "The checked types must be unshadowed builtins, undecorated module-local classes without an explicit metaclass, or proven stdlib ast classes; unresolved imports, runtime type groups, repeated type references, generated files, and non-terminating sibling checks are excluded.",
104
+ "The checked types must be unshadowed builtins, undecorated module-local classes without an explicit or known locally inherited custom metaclass, or proven stdlib ast classes; unresolved imports, runtime type groups, repeated type references, generated files, and non-terminating sibling checks are excluded.",
105
+ "Local ancestry must resolve to stable undeclared-metaclass classes or proven builtin/stdlib ast bases. External bases, runtime base aliases, and parameterized base expressions remain excluded because their metaclass or __class_getitem__ effects are unproven.",
105
106
  "Nested attribute-validation guards are excluded: converting them to keyword class patterns can turn attribute errors into match fallthrough, and an imported isinstance operand may be a runtime tuple rather than a class.",
106
107
  "A terminal-looking context-manager body does not prove a sibling branch terminates: exceptions can be suppressed. An unconditional return or raise after the context manager remains eligible.",
107
108
  "Declared support for Python before 3.10 suppresses this recommendation when proven by the nearest project metadata or exact installed-distribution ownership. Missing or ambiguous target metadata retains advisory behavior; it does not prove a modern target.",
@@ -239,20 +240,20 @@ class PreferMatchTypeDispatch(Rule):
239
240
  return []
240
241
  imports = context.imports
241
242
  all_nodes = tuple(context.nodes(ast.AST))
242
- unsafe_bindings = _unsafe_local_bound_names(tree, all_nodes) | frozenset(
243
+ unsafe_bindings = _unsafe_local_bound_names(tree, all_nodes, imports) | frozenset(
243
244
  qualified
244
245
  for node in all_nodes
245
246
  if isinstance(node, ast.Attribute)
246
247
  and isinstance(node.ctx, (ast.Store, ast.Del))
247
248
  and (qualified := imports.resolved_qualified_name(node)) is not None
248
- and qualified.startswith("ast.")
249
+ and qualified.startswith(("ast.", "builtins."))
249
250
  )
250
251
  has_wildcard_import = any(
251
252
  isinstance(node, ast.ImportFrom) and any(alias.name == "*" for alias in node.names) for node in all_nodes
252
253
  )
253
254
  if not imports.builtin_is_unshadowed("isinstance") or "isinstance" in unsafe_bindings or has_wildcard_import:
254
255
  return []
255
- local_classes = _unshadowed_module_classes(tree, all_nodes)
256
+ local_classes = _unshadowed_module_classes(tree, all_nodes, imports, unsafe_bindings)
256
257
  findings = _ladder_findings(
257
258
  all_nodes,
258
259
  path,
@@ -287,14 +288,10 @@ class PreferMatchTypeDispatch(Rule):
287
288
  return findings
288
289
 
289
290
 
290
- def _unshadowed_module_classes(tree: ast.Module, all_nodes: tuple[ast.AST, ...]) -> frozenset[str]:
291
- classes = [
292
- statement.name
293
- for statement in tree.body
294
- if isinstance(statement, ast.ClassDef)
295
- and not statement.decorator_list
296
- and not any(keyword.arg in {"metaclass", None} for keyword in statement.keywords)
297
- ]
291
+ def _unshadowed_module_classes(
292
+ tree: ast.Module, all_nodes: tuple[ast.AST, ...], imports: ImportIndex, unsafe_bindings: frozenset[str]
293
+ ) -> frozenset[str]:
294
+ declarations = [statement for statement in tree.body if isinstance(statement, ast.ClassDef)]
298
295
  rebound: set[str] = set()
299
296
  for node in all_nodes:
300
297
  match node:
@@ -310,17 +307,83 @@ def _unshadowed_module_classes(tree: ast.Module, all_nodes: tuple[ast.AST, ...])
310
307
  case _:
311
308
  pass
312
309
  _add_module_import_bindings(tree, rebound)
313
- return frozenset(name for name in classes if classes.count(name) == 1 and name not in rebound)
310
+ names = [declaration.name for declaration in declarations]
311
+ stable = [
312
+ declaration
313
+ for declaration in declarations
314
+ if names.count(declaration.name) == 1 and declaration.name not in rebound
315
+ ]
316
+ nominal = _nominal_class_ancestry(stable, imports, unsafe_bindings)
317
+ return frozenset(
318
+ declaration.name
319
+ for declaration in stable
320
+ if declaration.name in nominal
321
+ and not any(keyword.arg in {"metaclass", None} for keyword in declaration.keywords)
322
+ )
314
323
 
315
324
 
316
- def _unsafe_local_bound_names(tree: ast.Module, all_nodes: tuple[ast.AST, ...]) -> frozenset[str]:
317
- module_imports = {id(statement) for statement in tree.body if isinstance(statement, (ast.Import, ast.ImportFrom))}
318
- names = {
319
- alias.asname or alias.name.partition(".")[0]
320
- for statement in all_nodes
321
- if isinstance(statement, (ast.Import, ast.ImportFrom)) and id(statement) not in module_imports
322
- for alias in statement.names
323
- }
325
+ def _nominal_class_ancestry(
326
+ declarations: list[ast.ClassDef], imports: ImportIndex, unsafe_bindings: frozenset[str]
327
+ ) -> frozenset[str]:
328
+ proven: frozenset[str] = frozenset()
329
+ while True:
330
+ nominal = frozenset(
331
+ declaration.name
332
+ for declaration in declarations
333
+ if not declaration.decorator_list
334
+ and not _unproven_metaclass_header(declaration, imports, unsafe_bindings)
335
+ and all(_nominal_base(base, imports, proven, unsafe_bindings) for base in declaration.bases)
336
+ )
337
+ if nominal <= proven:
338
+ return proven
339
+ proven |= nominal
340
+
341
+
342
+ def _nominal_base(
343
+ expression: ast.expr, imports: ImportIndex, local_classes: frozenset[str], unsafe_bindings: frozenset[str]
344
+ ) -> bool:
345
+ if isinstance(expression, ast.Subscript):
346
+ return False
347
+ return _type_reference(expression, imports, local_classes, unsafe_bindings) is not None or (
348
+ _builtin_class_reference(expression, imports, unsafe_bindings) is not None
349
+ )
350
+
351
+
352
+ def _unproven_metaclass_header(
353
+ declaration: ast.ClassDef, imports: ImportIndex, unsafe_bindings: frozenset[str]
354
+ ) -> bool:
355
+ for keyword in declaration.keywords:
356
+ if keyword.arg is None:
357
+ return True
358
+ if keyword.arg != "metaclass":
359
+ continue
360
+ if _builtin_class_reference(keyword.value, imports, unsafe_bindings) == "type":
361
+ continue
362
+ return True
363
+ return False
364
+
365
+
366
+ def _builtin_class_reference(expression: ast.expr, imports: ImportIndex, unsafe_bindings: frozenset[str]) -> str | None:
367
+ root = expression
368
+ while isinstance(root, ast.Attribute):
369
+ root = root.value
370
+ if not isinstance(root, ast.Name) or root.id in unsafe_bindings:
371
+ return None
372
+ if (
373
+ isinstance(expression, ast.Name)
374
+ and expression.id in _BUILTIN_TYPES
375
+ and imports.builtin_is_unshadowed(expression.id)
376
+ and f"builtins.{expression.id}" not in unsafe_bindings
377
+ ):
378
+ return expression.id
379
+ symbol = imports.resolved_symbol(expression, sources=frozenset({"builtins"}))
380
+ if symbol in _BUILTIN_TYPES and f"builtins.{symbol}" not in unsafe_bindings:
381
+ return symbol
382
+ return None
383
+
384
+
385
+ def _unsafe_local_bound_names(tree: ast.Module, all_nodes: tuple[ast.AST, ...], imports: ImportIndex) -> frozenset[str]:
386
+ names = _unsafe_import_bound_names(tree, all_nodes, imports)
324
387
  names.update(
325
388
  candidate.name
326
389
  for candidate in all_nodes
@@ -334,6 +397,31 @@ def _unsafe_local_bound_names(tree: ast.Module, all_nodes: tuple[ast.AST, ...])
334
397
  return frozenset(names)
335
398
 
336
399
 
400
+ def _unsafe_import_bound_names(tree: ast.Module, all_nodes: tuple[ast.AST, ...], imports: ImportIndex) -> set[str]:
401
+ module_imports = {id(statement) for statement in tree.body if isinstance(statement, (ast.Import, ast.ImportFrom))}
402
+ names: set[str] = set()
403
+ for statement in all_nodes:
404
+ if not isinstance(statement, (ast.Import, ast.ImportFrom)):
405
+ continue
406
+ relative = isinstance(statement, ast.ImportFrom) and statement.level > 0
407
+ if id(statement) in module_imports and not relative:
408
+ continue
409
+ for alias in statement.names:
410
+ if not _preserves_indexed_import(statement, alias, imports):
411
+ names.add(alias.asname or alias.name.partition(".")[0])
412
+ return names
413
+
414
+
415
+ def _preserves_indexed_import(statement: ast.Import | ast.ImportFrom, alias: ast.alias, imports: ImportIndex) -> bool:
416
+ local = alias.asname or alias.name.partition(".")[0]
417
+ target = imports.bindings.get(local)
418
+ if target is None:
419
+ return False
420
+ if isinstance(statement, ast.ImportFrom):
421
+ return not statement.level and target.module == statement.module and target.symbol == alias.name
422
+ return target.module == alias.name and target.symbol is None
423
+
424
+
337
425
  def _ladder_findings(
338
426
  all_nodes: tuple[ast.AST, ...],
339
427
  path: Path,
@@ -632,6 +720,7 @@ def _type_reference(
632
720
  expression.id in _BUILTIN_TYPES
633
721
  and imports.builtin_is_unshadowed(expression.id)
634
722
  and expression.id not in unsafe_bindings
723
+ and f"builtins.{expression.id}" not in unsafe_bindings
635
724
  ):
636
725
  return expression.id
637
726
  if expression.id in local_classes and expression.id not in unsafe_bindings:
@@ -99,20 +99,23 @@ class PreferModuleLevelConstant(Rule):
99
99
  code: str = "SARJ039"
100
100
  documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
101
101
  default_level=Severity.WARNING,
102
- summary="Hoist repeatedly read static values when an immutable module representation preserves behavior.",
102
+ summary="Hoist repeatedly read static values when a module constant preserves behavior.",
103
103
  rationale=(
104
104
  "Rebuilding a substantial static collection repeats allocation, while calling `re.compile` with a "
105
- "constant pattern repeats a regex-cache lookup. In reusable code, an immutable module value makes "
106
- "the static lifetime explicit without exposing shared mutable state."
105
+ "constant pattern repeats a regex-cache lookup. Native immutable collections or Final dictionary bindings "
106
+ "make the static lifetime explicit for proven read-only use."
107
107
  ),
108
108
  remediation=(
109
- "Define the value once at module scope as a tuple, frozenset, immutable mapping, or compiled pattern, "
110
- "then reference it from the function. Preserve ordering and concrete-type behavior used by callers."
109
+ "Define the value once at module scope as a tuple, frozenset, Final[dict[K, V]], or compiled pattern, "
110
+ "then reference it from the function. Final prevents reassignment in type-checked code but does not freeze "
111
+ "dictionary contents; use MappingProxyType when runtime write protection is required. Preserve ordering "
112
+ "and concrete-type behavior used by callers."
111
113
  ),
112
114
  category=RuleCategory.PERFORMANCE,
113
115
  limitations=(
114
116
  "Test, test-support, and generated files are excluded.",
115
117
  "Collections require at least eight deeply immutable elements, or three when allocated inside a loop; only representation-insensitive reads are accepted.",
118
+ "Final dictionaries retain mutable contents at runtime; unknown calls, mutation, and escaping references are excluded from hoisting candidates.",
116
119
  "Regex findings require a proven stdlib `re.compile` binding and exclude `re.DEBUG`; the rule cannot prove that a function is hot, so findings remain advisory.",
117
120
  ),
118
121
  examples=(
@@ -144,6 +147,36 @@ class PreferModuleLevelConstant(Rule):
144
147
  expected_count=0,
145
148
  public=True,
146
149
  ),
150
+ RuleExample(
151
+ example_id="static-lookup-built-per-call",
152
+ scenario="keyed-values",
153
+ title="Static lookup dictionary rebuilt per call",
154
+ outcome=ExampleOutcome.MATCH,
155
+ files=(
156
+ ExampleFile.python(
157
+ "service.py",
158
+ "def handle(key):\n labels = {'a': 1, 'b': 2, 'c': 3, 'd': 4, 'e': 5, 'f': 6, 'g': 7, 'h': 8}\n return labels.get(key)\n",
159
+ ),
160
+ ),
161
+ focus_path=PurePosixPath("service.py"),
162
+ expected_count=1,
163
+ public=True,
164
+ ),
165
+ RuleExample(
166
+ example_id="final-module-lookup",
167
+ scenario="keyed-values",
168
+ title="Final lookup dictionary defined once",
169
+ outcome=ExampleOutcome.NO_MATCH,
170
+ files=(
171
+ ExampleFile.python(
172
+ "service.py",
173
+ "from typing import Final\n\nLABELS: Final[dict[str, int]] = {'a': 1, 'b': 2, 'c': 3, 'd': 4, 'e': 5, 'f': 6, 'g': 7, 'h': 8}\n\ndef handle(key):\n return LABELS.get(key)\n",
174
+ ),
175
+ ),
176
+ focus_path=PurePosixPath("service.py"),
177
+ expected_count=0,
178
+ public=True,
179
+ ),
147
180
  ),
148
181
  )
149
182
  description: str = documentation.summary
@@ -600,6 +633,12 @@ def _is_excluded_test_path(path: Path) -> bool:
600
633
  def _message(name: str, candidate: _Candidate) -> str:
601
634
  if candidate.kind == _REGEX_KIND:
602
635
  return f"`{name}` repeats a regex-cache lookup on every call — hoist it to module scope."
636
+ if candidate.kind == "dict":
637
+ return (
638
+ f"`{name}` is a constant-only dictionary rebuilt on every call — hoist it to module scope as "
639
+ "Final[dict[K, V]] for read-only lookup use. Final prevents reassignment in type-checked code but does not "
640
+ "freeze dictionary contents; use MappingProxyType when runtime write protection is required."
641
+ )
603
642
  return (
604
643
  f"`{name}` is a constant-only {candidate.kind} rebuilt on every call — hoist it "
605
644
  "to module scope in immutable form (tuple, frozenset, or an immutable mapping) "