sarj-python-lint 1.30.0__tar.gz → 1.31.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 (199) hide show
  1. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_registry.py +2 -0
  4. sarj_python_lint-1.31.0/src/sarj_python_lint/rules/prefer_declarative_batch_validation.py +655 -0
  5. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/.gitignore +0 -0
  6. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/LICENSE +0 -0
  7. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/README.md +0 -0
  8. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/__init__.py +0 -0
  9. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/__main__.py +0 -0
  10. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/_analysis_session.py +0 -0
  11. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/_file_context.py +0 -0
  12. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/_filesystem.py +0 -0
  13. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/_python_target.py +0 -0
  14. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  15. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/_secret_names.py +0 -0
  16. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/_source.py +0 -0
  17. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/_version.py +0 -0
  18. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/benchmark.py +0 -0
  19. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/interpreter_argv.py +0 -0
  20. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/json_boundary.py +0 -0
  21. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/py.typed +0 -0
  22. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/ratchet.py +0 -0
  23. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rule_base.py +0 -0
  24. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  25. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
  26. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  27. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_ast_position.py +0 -0
  28. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_closed_helpers.py +0 -0
  29. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  30. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_contract_annotation.py +0 -0
  31. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_copy_on_write.py +0 -0
  32. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_dict_key_views.py +0 -0
  33. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  34. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_exports.py +0 -0
  35. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  36. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  37. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  38. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  39. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_local_source.py +0 -0
  40. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  41. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_namespace_access.py +0 -0
  42. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_nominal_project.py +0 -0
  43. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  44. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
  45. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  46. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_pydantic_field_contract.py +0 -0
  47. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  48. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_resource_provenance.py +0 -0
  49. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_set_fields.py +0 -0
  50. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  51. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  52. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  53. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_test_composition.py +0 -0
  54. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/_test_provenance.py +0 -0
  55. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/async_cleanup_registered_synchronously.py +0 -0
  56. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +0 -0
  57. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  58. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  59. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  60. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  61. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/duplicate_adjacent_exception_handler.py +0 -0
  62. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/duplicate_closed_pure_helper.py +0 -0
  63. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  64. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  65. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
  66. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  67. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  68. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  69. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  70. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
  71. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  72. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  73. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
  74. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_before_validator_input_mutation.py +0 -0
  75. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_broad_keyword_capture.py +0 -0
  76. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  77. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_conflicting_pydantic_alias_validation.py +0 -0
  78. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  79. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  80. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  81. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_database_programmability.py +0 -0
  82. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
  83. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  84. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  85. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  86. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  87. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  88. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  89. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
  90. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_fixed_sleep_in_loop.py +0 -0
  91. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  92. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  93. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  94. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_impossible_pydantic_field_bounds.py +0 -0
  95. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_injected_module_loader.py +0 -0
  96. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_input_model_mutation.py +0 -0
  97. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_interpreter_source_arguments.py +0 -0
  98. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
  99. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_mocked_pydantic_value_object.py +0 -0
  100. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  101. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_nullable_dependency_fallback.py +0 -0
  102. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  103. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  104. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  105. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  106. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  107. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  108. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  109. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  110. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  111. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  112. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  113. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  114. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  115. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  116. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_shallow_container_type_guard.py +0 -0
  117. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  118. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
  119. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  120. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_test_method_grafting.py +0 -0
  121. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  122. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  123. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_unknown_pydantic_validator_field.py +0 -0
  124. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  125. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_unused_copy_result.py +0 -0
  126. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_unused_underscored_keyword_parameter.py +0 -0
  127. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
  128. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  129. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  130. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  131. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  132. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  133. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_autospec_for_callable_mock.py +0 -0
  134. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  135. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  136. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  137. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  138. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  139. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
  140. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  141. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  142. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
  143. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  144. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  145. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  146. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
  147. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_monotonic_for_elapsed_time.py +0 -0
  148. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  149. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_native_string_check.py +0 -0
  150. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  151. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  152. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  153. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
  154. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_pytest_fixture_injection.py +0 -0
  155. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_regex_fullmatch.py +0 -0
  156. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_required_constructor_parameters.py +0 -0
  157. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  158. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  159. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  160. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  161. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  162. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  163. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  164. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  165. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  166. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  167. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  168. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  169. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  170. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  171. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/redundant_test_constructor_forwarder.py +0 -0
  172. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  173. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  174. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
  175. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/repeated_unimplemented_test_contract.py +0 -0
  176. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/require_explicit_contract_implementation.py +0 -0
  177. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/require_explicit_psycopg_transaction.py +0 -0
  178. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/require_explicit_service_contract.py +0 -0
  179. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/require_injectable_retry_sleep.py +0 -0
  180. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  181. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  182. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  183. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/require_precise_factory_signature.py +0 -0
  184. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/require_public_dependency_contract.py +0 -0
  185. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  186. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
  187. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  188. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  189. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/shared_mutable_pydantic_factory.py +0 -0
  190. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  191. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  192. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  193. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/subprocess_kill_without_reap.py +0 -0
  194. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  195. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  196. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  197. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  198. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  199. {sarj_python_lint-1.30.0 → sarj_python_lint-1.31.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.30.0
3
+ Version: 1.31.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.30.0"
3
+ version = "1.31.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" }]
@@ -112,6 +112,7 @@ from sarj_python_lint.rules.prefer_collection_comprehension import PreferCollect
112
112
  from sarj_python_lint.rules.prefer_constant_time_secret_compare import (
113
113
  PreferConstantTimeSecretCompare,
114
114
  )
115
+ from sarj_python_lint.rules.prefer_declarative_batch_validation import PreferDeclarativeBatchValidation
115
116
  from sarj_python_lint.rules.prefer_fstring_over_concat import PreferFstringOverConcat
116
117
  from sarj_python_lint.rules.prefer_immutable_module_constant import (
117
118
  PreferImmutableModuleConstant,
@@ -245,6 +246,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
245
246
  NoStringConcatInLoop.id: NoStringConcatInLoop,
246
247
  PreferClassRow.id: PreferClassRow,
247
248
  PreferCollectionComprehension.id: PreferCollectionComprehension,
249
+ PreferDeclarativeBatchValidation.id: PreferDeclarativeBatchValidation,
248
250
  PreferStrEnum.id: PreferStrEnum,
249
251
  NoOffsetPagination.id: NoOffsetPagination,
250
252
  NoExcessiveCognitiveComplexity.id: NoExcessiveCognitiveComplexity,
@@ -0,0 +1,655 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from dataclasses import dataclass
5
+ from functools import cached_property
6
+ from pathlib import PurePosixPath
7
+ from typing import TYPE_CHECKING, ClassVar, final, override
8
+
9
+ from sarj_python_lint.rule_base import (
10
+ AutofixPolicy,
11
+ Diagnostic,
12
+ ExampleFile,
13
+ ExampleOutcome,
14
+ Rule,
15
+ RuleCategory,
16
+ RuleDocumentation,
17
+ RuleExample,
18
+ Severity,
19
+ is_suppressed,
20
+ )
21
+ from sarj_python_lint.rules._imports import ImportIndex
22
+ from sarj_python_lint.rules._logging import LOG_METHODS
23
+ from sarj_python_lint.rules._paths import is_test_path
24
+ from sarj_python_lint.rules._resource_provenance import ResourceProvenance
25
+ from sarj_python_lint.rules.prefer_class_row import PreferClassRow
26
+
27
+
28
+ if TYPE_CHECKING:
29
+ from sarj_python_lint._file_context import PythonFileContext
30
+
31
+
32
+ _BASE_MODELS = frozenset({"pydantic.BaseModel", "pydantic.main.BaseModel"})
33
+ _VALIDATION_ERRORS = frozenset({"pydantic.ValidationError", "pydantic_core.ValidationError"})
34
+ _SEQUENCES = frozenset({"typing.List", "typing.Tuple", "typing.Sequence", "collections.abc.Sequence"})
35
+ _ROW_FACTORIES = frozenset({"psycopg.rows.dict_row", "psycopg.rows.class_row"})
36
+ _SCOPES = (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class _Batch:
41
+ factory: ast.expr | None = None
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class _Builder:
46
+ name: str
47
+ initializer: ast.Assign | ast.AnnAssign
48
+ owner: ast.FunctionDef | ast.AsyncFunctionDef
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class _ValidatedAppend:
53
+ value: ast.expr
54
+ local: str | None = None
55
+
56
+
57
+ @final
58
+ class PreferDeclarativeBatchValidation(Rule):
59
+ id = "prefer-declarative-batch-validation"
60
+ code = "SARJ494"
61
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
62
+ default_level=Severity.WARNING,
63
+ summary="Validate a homogeneous Pydantic batch with a declared collection contract.",
64
+ rationale=(
65
+ "Per-record validation comprehensions and catch/log/drop builders spread a collection contract "
66
+ "across repeated model calls and mutable error-handling scaffolding. A named collection schema "
67
+ "makes the batch's error policy explicit."
68
+ ),
69
+ remediation=(
70
+ "Use class_row(Model) for unchanged Psycopg model rows, or a named RootModel[list[Model]] for "
71
+ "collection validation. Declare whether invalid records reject the batch or are intentionally omitted "
72
+ "(for example with OnErrorOmit). Preserve required logging and rejection reports."
73
+ ),
74
+ category=RuleCategory.MAINTAINABILITY,
75
+ autofix=AutofixPolicy.NONE,
76
+ limitations=(
77
+ "Only locally proven, undecorated Pydantic BaseModel classes, local inheritance and unique aliases are recognized; imported application models and dynamic ancestry remain unreported.",
78
+ "Inputs must be a literal collection, an unambiguous list/tuple/Sequence declaration, or materialized rows from a locally bound Psycopg dict_row/class_row cursor. Unknown and streaming iterables are excluded.",
79
+ "Comprehensions must have one synchronous generator and no filter. Loops require an adjacent fresh list and one validation-only try block with a proven ValidationError handler that only logs and drops a record.",
80
+ "Changing models or context, preprocessing, rejection reports, secondary effects, observable loop locals, and validation options other than literal from_attributes=True are excluded.",
81
+ "Simple append loops remain owned by Ruff PERF401; its resulting model-validation comprehension is checked here. Proven SARJ013 cursor conversions take precedence over this warning.",
82
+ "There is no autofix: collection validation can change error aggregation, record omission, logging, evaluation after the first invalid record, and failure timing. Tests, generated files and vendor sources are excluded.",
83
+ ),
84
+ examples=(
85
+ RuleExample(
86
+ example_id="homogeneous-validation-comprehension",
87
+ title="Declare collection validation once",
88
+ outcome=ExampleOutcome.MATCH,
89
+ files=(
90
+ ExampleFile.python(
91
+ "app/batches.py",
92
+ "from pydantic import BaseModel\nclass Item(BaseModel):\n value: int\n"
93
+ "def load(rows: list[object]):\n return [Item.model_validate(row) for row in rows]\n",
94
+ ),
95
+ ),
96
+ focus_path=PurePosixPath("app/batches.py"),
97
+ expected_count=1,
98
+ public=True,
99
+ ),
100
+ RuleExample(
101
+ example_id="named-batch-validation",
102
+ title="A named collection schema owns the batch",
103
+ outcome=ExampleOutcome.NO_MATCH,
104
+ files=(
105
+ ExampleFile.python(
106
+ "app/batches.py",
107
+ "from pydantic import BaseModel, RootModel\nclass Item(BaseModel):\n value: int\n"
108
+ "class Items(RootModel[list[Item]]):\n pass\n"
109
+ "def load(rows: list[object]):\n return Items.model_validate(rows).root\n",
110
+ ),
111
+ ),
112
+ focus_path=PurePosixPath("app/batches.py"),
113
+ expected_count=0,
114
+ public=True,
115
+ ),
116
+ ),
117
+ )
118
+ description = documentation.summary
119
+
120
+ @override
121
+ def check_context(self, context: PythonFileContext) -> list[Diagnostic]:
122
+ if (
123
+ context.generated
124
+ or is_test_path(context.path)
125
+ or not {"vendor", "vendored", "third_party"}.isdisjoint(context.path.parts)
126
+ or "model_validate" not in context.symbol_source
127
+ or context.tree is None
128
+ ):
129
+ return []
130
+ candidates = [node for node in context.nodes(ast.ListComp, ast.For) if _may_validate_batch(node)]
131
+ if not candidates:
132
+ return []
133
+ facts = _Facts(context)
134
+ if facts.provenance.wildcard or facts.provenance.dynamic_namespace:
135
+ return []
136
+ findings: list[Diagnostic] = []
137
+ for node in candidates:
138
+ matched = _comprehension(node, facts) if isinstance(node, ast.ListComp) else _drop_loop(node, facts)
139
+ if matched is None:
140
+ continue
141
+ model, batch = matched
142
+ if (
143
+ batch.factory is not None
144
+ and (batch.factory.lineno, batch.factory.col_offset + 1) in facts.database_owners
145
+ ):
146
+ continue
147
+ if is_suppressed(context.source_lines, node.lineno, self.code):
148
+ continue
149
+ findings.append(
150
+ Diagnostic(
151
+ path=context.path,
152
+ line=node.lineno,
153
+ col=node.col_offset + 1,
154
+ code=self.code,
155
+ severity=Severity.WARNING,
156
+ message=(
157
+ f"per-record validation of `{model}` builds a homogeneous batch; use class_row({model}) "
158
+ "for unchanged database rows, or a named RootModel collection with an explicit reject-or-omit "
159
+ "policy; preserve required error reporting and logging"
160
+ ),
161
+ )
162
+ )
163
+ return sorted(findings, key=lambda finding: (finding.line, finding.col))
164
+
165
+
166
+ class _Facts:
167
+ def __init__(self, context: PythonFileContext) -> None:
168
+ self.context = context
169
+ self.provenance = ResourceProvenance(context, require_static_namespace=True)
170
+ self.imports = {
171
+ statement: ImportIndex.from_tree(ast.Module(body=[statement], type_ignores=[]))
172
+ for statement in context.nodes(ast.Import, ast.ImportFrom)
173
+ }
174
+ self._binding_cache: dict[tuple[str, ast.AST | None], tuple[ast.AST, ...]] = {}
175
+ self._read_cache: dict[ast.AST, tuple[ast.Name, ...]] = {}
176
+ self._import_cache: dict[ast.alias, bool] = {}
177
+ self._type_cache: dict[ast.AST, bool] = {}
178
+
179
+ @cached_property
180
+ def database_owners(self) -> set[tuple[int, int]]:
181
+ return {(finding.line, finding.col) for finding in PreferClassRow().check_context(self.context)}
182
+
183
+ def bindings(self, name: str, at: ast.AST) -> tuple[ast.AST, ...]:
184
+ scope = self.provenance.scope(at)
185
+ key = (name, scope)
186
+ if key not in self._binding_cache:
187
+ self._binding_cache[key] = self._lexical_bindings(name, scope)
188
+ return self._binding_cache[key]
189
+
190
+ def _lexical_bindings(self, name: str, scope: ast.AST | None) -> tuple[ast.AST, ...]:
191
+ initial = scope
192
+ while scope is not None:
193
+ if (scope is initial or not isinstance(scope, ast.ClassDef)) and (
194
+ nodes := self.provenance.bindings.get(scope, {}).get(name)
195
+ ):
196
+ return tuple(nodes)
197
+ scope = self.provenance.scope(scope)
198
+ return ()
199
+
200
+ def _reads_for(self, name: str, binding: ast.AST) -> tuple[ast.Name, ...]:
201
+ if binding not in self._read_cache:
202
+ self._read_cache[binding] = tuple(
203
+ read for read in self.provenance.reads.get(name, ()) if self.bindings(name, read) == (binding,)
204
+ )
205
+ return self._read_cache[binding]
206
+
207
+ def qualified(self, expression: ast.expr) -> str | None:
208
+ root = _root(expression)
209
+ if root is None or root.id in self.provenance.mutated | self.provenance.external_bindings:
210
+ return None
211
+ bindings = self.bindings(root.id, expression)
212
+ if len(bindings) != 1 or not isinstance(bindings[0], ast.alias):
213
+ return None
214
+ owner = self.context.parents.get(bindings[0])
215
+ index = self.imports.get(owner) if isinstance(owner, (ast.Import, ast.ImportFrom)) else None
216
+ if index is None or not self._import_untouched(root.id, bindings[0], index):
217
+ return None
218
+ return _import_name(expression, index)
219
+
220
+ def _import_untouched(self, name: str, binding: ast.alias, index: ImportIndex) -> bool:
221
+ if binding not in self._import_cache:
222
+ self._import_cache[binding] = all(
223
+ self._safe_import_read(read, index) for read in self._reads_for(name, binding)
224
+ )
225
+ return self._import_cache[binding]
226
+
227
+ def _safe_import_read(self, read: ast.Name, index: ImportIndex) -> bool:
228
+ reference: ast.expr = read
229
+ parent = self.context.parents.get(reference)
230
+ while isinstance(parent, ast.Attribute) and parent.value is reference:
231
+ reference, parent = parent, self.context.parents.get(parent)
232
+ match parent:
233
+ case ast.Call(func=func) if func is reference:
234
+ return not (isinstance(reference, ast.Attribute) and reference.attr in {"__setattr__", "__delattr__"})
235
+ case ast.ClassDef(bases=bases):
236
+ return reference in bases
237
+ case ast.Subscript():
238
+ return True
239
+ case (
240
+ ast.arg(annotation=slot)
241
+ | ast.AnnAssign(annotation=slot)
242
+ | ast.FunctionDef(returns=slot)
243
+ | ast.AsyncFunctionDef(returns=slot)
244
+ | ast.ExceptHandler(type=slot)
245
+ ):
246
+ return slot is reference
247
+ case ast.keyword(arg="row_factory"):
248
+ return self._row_factory_reference(reference, parent, index)
249
+ case _:
250
+ return False
251
+
252
+ def _row_factory_reference(self, reference: ast.expr, keyword: ast.keyword, index: ImportIndex) -> bool:
253
+ call = self.context.parents.get(keyword)
254
+ return (
255
+ isinstance(call, ast.Call)
256
+ and isinstance(call.func, ast.Attribute)
257
+ and call.func.attr == "cursor"
258
+ and _import_name(reference, index) in _ROW_FACTORIES
259
+ )
260
+
261
+ def builtin(self, name: str, at: ast.AST) -> bool:
262
+ return not self.bindings(name, at) and self.provenance.builtin_namespace_untouched
263
+
264
+ def model(self, expression: ast.expr, seen: frozenset[ast.AST] = frozenset()) -> bool:
265
+ if not isinstance(expression, ast.Name) or expression.id in self.provenance.external_bindings:
266
+ return False
267
+ bindings = self.bindings(expression.id, expression)
268
+ if len(bindings) != 1 or bindings[0] in seen or not self._safe_type_binding(bindings[0], seen):
269
+ return False
270
+ binding = bindings[0]
271
+ if isinstance(binding, ast.Name):
272
+ value = self.provenance.assignment_value(binding, expression)
273
+ return value is not None and self.model(value, seen | {binding})
274
+ if not isinstance(binding, ast.ClassDef) or not isinstance(
275
+ self.context.parents.get(binding), (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef)
276
+ ):
277
+ return False
278
+ if binding.decorator_list or binding.keywords or len(binding.bases) != 1:
279
+ return False
280
+ if any(_binds_validation(member) for member in binding.body):
281
+ return False
282
+ base = binding.bases[0]
283
+ return self.qualified(base) in _BASE_MODELS or self.model(base, seen | {binding})
284
+
285
+ def _safe_type_binding(self, binding: ast.AST, seen: frozenset[ast.AST]) -> bool:
286
+ name = (
287
+ binding.name if isinstance(binding, ast.ClassDef) else binding.id if isinstance(binding, ast.Name) else None
288
+ )
289
+ if name is None or name in self.provenance.mutated | self.provenance.external_bindings:
290
+ return False
291
+ if self.bindings(name, binding) != (binding,):
292
+ return False
293
+ if binding not in self._type_cache:
294
+ self._type_cache[binding] = all(
295
+ self._safe_type_read(read, seen | {binding}) for read in self._reads_for(name, binding)
296
+ )
297
+ return self._type_cache[binding]
298
+
299
+ def _safe_type_read(self, read: ast.Name, seen: frozenset[ast.AST]) -> bool:
300
+ parent = self.context.parents.get(read)
301
+ match parent:
302
+ case ast.Attribute(value=value, attr="model_validate"):
303
+ return value is read
304
+ case ast.ClassDef(bases=bases):
305
+ return read in bases
306
+ case ast.Call():
307
+ return parent.func is read or self._class_row_consumer(parent, read)
308
+ case ast.Subscript() | ast.arg():
309
+ return _in_annotation(read, self)
310
+ case ast.Assign() | ast.AnnAssign():
311
+ return _in_annotation(read, self) or self._type_alias(parent, read, seen)
312
+ case _:
313
+ return False
314
+
315
+ def _class_row_consumer(self, call: ast.Call, read: ast.Name) -> bool:
316
+ return call.args == [read] and not call.keywords and self.qualified(call.func) == "psycopg.rows.class_row"
317
+
318
+ def _type_alias(self, assignment: ast.Assign | ast.AnnAssign, read: ast.Name, seen: frozenset[ast.AST]) -> bool:
319
+ if assignment.value is not read:
320
+ return False
321
+ targets = assignment.targets if isinstance(assignment, ast.Assign) else [assignment.target]
322
+ if len(targets) != 1 or not isinstance(targets[0], ast.Name):
323
+ return False
324
+ return targets[0] in seen or self._safe_type_binding(targets[0], seen)
325
+
326
+ def batch(self, expression: ast.expr, seen: frozenset[ast.AST] = frozenset()) -> _Batch | None:
327
+ if isinstance(expression, (ast.List, ast.Tuple)):
328
+ return _Batch()
329
+ if isinstance(expression, ast.Name):
330
+ return self._named_batch(expression, seen)
331
+ value = expression.value if isinstance(expression, ast.Await) else expression
332
+ if (
333
+ not isinstance(value, ast.Call)
334
+ or not isinstance(value.func, ast.Attribute)
335
+ or value.func.attr not in {"fetchall", "fetchmany"}
336
+ or not isinstance(value.func.value, ast.Name)
337
+ ):
338
+ return None
339
+ return self._materialized_batch(value, value.func.value)
340
+
341
+ def _named_batch(self, expression: ast.Name, seen: frozenset[ast.AST]) -> _Batch | None:
342
+ bindings = self.bindings(expression.id, expression)
343
+ if len(bindings) != 1 or bindings[0] in seen or expression.id in self.provenance.external_bindings:
344
+ return None
345
+ binding = bindings[0]
346
+ if isinstance(binding, ast.Name):
347
+ value = self._batch_assignment(binding, expression)
348
+ if value is not None:
349
+ return self.batch(value, seen | {binding})
350
+ parent = self.context.parents.get(binding)
351
+ annotation = (
352
+ binding.annotation
353
+ if isinstance(binding, ast.arg)
354
+ else (parent.annotation if isinstance(parent, ast.AnnAssign) else None)
355
+ )
356
+ return _Batch() if annotation is not None and self._sequence_annotation(annotation) else None
357
+
358
+ def _batch_assignment(self, binding: ast.Name, at: ast.Name) -> ast.expr | None:
359
+ value = self.provenance.assignment_value(binding, at)
360
+ if value is not None:
361
+ return value
362
+ assignment = self.context.parents.get(binding)
363
+ if not isinstance(assignment, (ast.Assign, ast.AnnAssign)) or assignment.lineno >= at.lineno:
364
+ return None
365
+ container = self.context.parents.get(assignment)
366
+ return assignment.value if isinstance(container, (ast.With, ast.AsyncWith)) else None
367
+
368
+ def _materialized_batch(self, fetch: ast.Call, receiver: ast.Name) -> _Batch | None:
369
+ if receiver.id in self.provenance.mutated | self.provenance.external_bindings:
370
+ return None
371
+ cursor = self._cursor_definition(receiver, fetch)
372
+ if (
373
+ not isinstance(cursor, ast.Call)
374
+ or not isinstance(cursor.func, ast.Attribute)
375
+ or cursor.func.attr != "cursor"
376
+ ):
377
+ return None
378
+ factory = next((keyword.value for keyword in cursor.keywords if keyword.arg == "row_factory"), None)
379
+ reference = factory.func if isinstance(factory, ast.Call) else factory
380
+ return _Batch(factory) if reference is not None and self.qualified(reference) in _ROW_FACTORIES else None
381
+
382
+ def _cursor_definition(self, receiver: ast.Name, at: ast.Call) -> ast.expr | None:
383
+ bindings = self.bindings(receiver.id, at)
384
+ if len(bindings) != 1:
385
+ return None
386
+ binding = bindings[0]
387
+ parent = self.context.parents.get(binding)
388
+ if isinstance(parent, ast.withitem):
389
+ owner = self.context.parents.get(parent)
390
+ return parent.context_expr if self.provenance.contains(owner, at) else None
391
+ return self.provenance.assignment_value(binding, at) if isinstance(binding, ast.Name) else None
392
+
393
+ def _sequence_annotation(self, annotation: ast.expr) -> bool:
394
+ if not isinstance(annotation, ast.Subscript):
395
+ return False
396
+ value = annotation.value
397
+ return (
398
+ isinstance(value, ast.Name) and value.id in {"list", "tuple"} and self.builtin(value.id, value)
399
+ ) or self.qualified(value) in _SEQUENCES
400
+
401
+ def logging(self, statement: ast.stmt, error: str | None) -> bool:
402
+ if not isinstance(statement, ast.Expr) or not isinstance(statement.value, ast.Call):
403
+ return False
404
+ call = statement.value
405
+ if not isinstance(call.func, ast.Attribute) or call.func.attr not in LOG_METHODS:
406
+ return False
407
+ target = self.qualified(call.func)
408
+ if target not in {f"loguru.logger.{call.func.attr}", f"logging.{call.func.attr}"}:
409
+ return False
410
+ for value in (*call.args, *(keyword.value for keyword in call.keywords)):
411
+ for node in ast.walk(value):
412
+ if isinstance(node, (ast.Await, ast.Yield, ast.YieldFrom, ast.NamedExpr, ast.Lambda)):
413
+ return False
414
+ if isinstance(node, ast.Call) and not (
415
+ isinstance(node.func, ast.Name)
416
+ and node.func.id == "str"
417
+ and self.builtin("str", node)
418
+ and len(node.args) == 1
419
+ and not node.keywords
420
+ and isinstance(node.args[0], ast.Name)
421
+ and node.args[0].id == error
422
+ ):
423
+ return False
424
+ return not any(keyword.arg is None for keyword in call.keywords)
425
+
426
+
427
+ def _root(expression: ast.expr) -> ast.Name | None:
428
+ while isinstance(expression, ast.Attribute):
429
+ expression = expression.value
430
+ return expression if isinstance(expression, ast.Name) else None
431
+
432
+
433
+ def _import_name(expression: ast.expr, index: ImportIndex) -> str | None:
434
+ root = _root(expression)
435
+ target = index.bindings.get(root.id) if root is not None else None
436
+ if target is None:
437
+ return None
438
+ parts: list[str] = []
439
+ while isinstance(expression, ast.Attribute):
440
+ parts.append(expression.attr)
441
+ expression = expression.value
442
+ return ".".join((target.module, *((target.symbol,) if target.symbol else ()), *reversed(parts)))
443
+
444
+
445
+ def _binds_validation(statement: ast.stmt) -> bool:
446
+ if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
447
+ return statement.name == "model_validate"
448
+ for node in ast.walk(statement):
449
+ match node:
450
+ case (
451
+ ast.FunctionDef(name="model_validate")
452
+ | ast.AsyncFunctionDef(name="model_validate")
453
+ | ast.ClassDef(name="model_validate")
454
+ | ast.Name(id="model_validate", ctx=(ast.Store() | ast.Del()))
455
+ | ast.ExceptHandler(name="model_validate")
456
+ | ast.MatchAs(name="model_validate")
457
+ ):
458
+ return True
459
+ case ast.Import(names=aliases) | ast.ImportFrom(names=aliases):
460
+ if any((alias.asname or alias.name.partition(".")[0]) == "model_validate" for alias in aliases):
461
+ return True
462
+ case _:
463
+ pass
464
+ return False
465
+
466
+
467
+ def _in_annotation(node: ast.AST, facts: _Facts) -> bool:
468
+ context = facts.context
469
+ child = node
470
+ parent = context.parents.get(child)
471
+ while parent is not None and not isinstance(parent, _SCOPES):
472
+ if isinstance(parent, (ast.arg, ast.AnnAssign)):
473
+ return parent.annotation is child
474
+ if isinstance(parent, ast.Call):
475
+ return False
476
+ child, parent = parent, context.parents.get(parent)
477
+ if isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef)):
478
+ return parent.returns is child
479
+ return (
480
+ isinstance(parent, ast.ClassDef)
481
+ and child in parent.bases
482
+ and isinstance(child, ast.Subscript)
483
+ and facts.qualified(child.value) in {"pydantic.RootModel", "pydantic.root_model.RootModel"}
484
+ )
485
+
486
+
487
+ def _validation(value: ast.expr, item: str, facts: _Facts) -> str | None:
488
+ if (
489
+ not isinstance(value, ast.Call)
490
+ or not isinstance(value.func, ast.Attribute)
491
+ or value.func.attr != "model_validate"
492
+ ):
493
+ return None
494
+ if (
495
+ len(value.args) != 1
496
+ or not isinstance(value.args[0], ast.Name)
497
+ or value.args[0].id != item
498
+ or not facts.model(value.func.value)
499
+ ):
500
+ return None
501
+ if value.keywords and not (
502
+ len(value.keywords) == 1
503
+ and value.keywords[0].arg == "from_attributes"
504
+ and isinstance(value.keywords[0].value, ast.Constant)
505
+ and value.keywords[0].value.value is True
506
+ ):
507
+ return None
508
+ return ast.unparse(value.func.value)
509
+
510
+
511
+ def _comprehension(node: ast.ListComp, facts: _Facts) -> tuple[str, _Batch] | None:
512
+ if len(node.generators) != 1:
513
+ return None
514
+ generator = node.generators[0]
515
+ if generator.is_async or generator.ifs or not isinstance(generator.target, ast.Name):
516
+ return None
517
+ batch = facts.batch(generator.iter)
518
+ if batch is None:
519
+ return None
520
+ model = _validation(node.elt, generator.target.id, facts)
521
+ return (model, batch) if model is not None else None
522
+
523
+
524
+ def _may_validate_batch(node: ast.ListComp | ast.For) -> bool:
525
+ if isinstance(node, ast.ListComp):
526
+ return _is_validation_call(node.elt)
527
+ if not node.body or not isinstance(node.body[0], ast.Try):
528
+ return False
529
+ match node.body[0].body:
530
+ case [ast.Assign(value=value), *_] | [
531
+ ast.Expr(value=ast.Call(func=ast.Attribute(attr="append"), args=[value])),
532
+ *_,
533
+ ]:
534
+ return _is_validation_call(value)
535
+ case _:
536
+ return False
537
+
538
+
539
+ def _is_validation_call(value: ast.expr) -> bool:
540
+ return isinstance(value, ast.Call) and isinstance(value.func, ast.Attribute) and value.func.attr == "model_validate"
541
+
542
+
543
+ def _drop_loop(loop: ast.For, facts: _Facts) -> tuple[str, _Batch] | None:
544
+ if loop.orelse or not isinstance(loop.target, ast.Name):
545
+ return None
546
+ builder = _fresh_builder(loop, facts)
547
+ if builder is None:
548
+ return None
549
+ attempt = _logged_attempt(loop, facts)
550
+ if attempt is None:
551
+ return None
552
+ appended = _validated_append(loop, attempt, builder.name)
553
+ if appended is None:
554
+ return None
555
+ locals_ = {loop.target.id, *(() if appended.local is None else (appended.local,))}
556
+ if _locals_observed(loop, locals_, facts):
557
+ return None
558
+ if _collection_observed(loop, builder.initializer, builder.owner, builder.name, facts):
559
+ return None
560
+ batch = facts.batch(loop.iter)
561
+ if batch is None:
562
+ return None
563
+ model = _validation(appended.value, loop.target.id, facts)
564
+ return (model, batch) if model is not None else None
565
+
566
+
567
+ def _fresh_builder(loop: ast.For, facts: _Facts) -> _Builder | None:
568
+ parent = facts.context.parents.get(loop)
569
+ if not isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef, ast.With, ast.AsyncWith)):
570
+ return None
571
+ owner: ast.AST | None = parent
572
+ while owner is not None and not isinstance(owner, _SCOPES):
573
+ owner = facts.context.parents.get(owner)
574
+ if not isinstance(owner, (ast.FunctionDef, ast.AsyncFunctionDef)):
575
+ return None
576
+ position = parent.body.index(loop)
577
+ if position == 0:
578
+ return None
579
+ initializer = parent.body[position - 1]
580
+ match initializer:
581
+ case (
582
+ ast.Assign(targets=[ast.Name(id=name)], value=ast.List(elts=[]))
583
+ | ast.AnnAssign(target=ast.Name(id=name), value=ast.List(elts=[]), simple=1)
584
+ ):
585
+ return _Builder(name, initializer, owner)
586
+ case _:
587
+ return None
588
+
589
+
590
+ def _logged_attempt(loop: ast.For, facts: _Facts) -> ast.Try | None:
591
+ if not loop.body or not isinstance(loop.body[0], ast.Try):
592
+ return None
593
+ attempt = loop.body[0]
594
+ if attempt.finalbody or len(attempt.handlers) != 1:
595
+ return None
596
+ handler = attempt.handlers[0]
597
+ if handler.type is None or facts.qualified(handler.type) not in _VALIDATION_ERRORS:
598
+ return None
599
+ logging = handler.body[:-1] if isinstance(handler.body[-1], ast.Continue) else handler.body
600
+ if not logging or not all(facts.logging(statement, handler.name) for statement in logging):
601
+ return None
602
+ return attempt
603
+
604
+
605
+ def _validated_append(loop: ast.For, attempt: ast.Try, name: str) -> _ValidatedAppend | None:
606
+ if len(attempt.body) == 1 and not attempt.orelse and len(loop.body) == 1:
607
+ value = _append(attempt.body[0], name)
608
+ if value is not None:
609
+ return _ValidatedAppend(value)
610
+ match attempt.body[0]:
611
+ case ast.Assign(targets=[ast.Name(id=local)], value=value):
612
+ pass
613
+ case _:
614
+ return None
615
+ tail = [*attempt.body[1:], *attempt.orelse, *loop.body[1:]]
616
+ if len(tail) != 1:
617
+ return None
618
+ appended = _append(tail[0], name)
619
+ if not isinstance(appended, ast.Name) or appended.id != local:
620
+ return None
621
+ if len(loop.body) > 1 and not isinstance(attempt.handlers[0].body[-1], ast.Continue):
622
+ return None
623
+ return _ValidatedAppend(value, local)
624
+
625
+
626
+ def _append(statement: ast.stmt, name: str) -> ast.expr | None:
627
+ match statement:
628
+ case ast.Expr(
629
+ value=ast.Call(func=ast.Attribute(value=ast.Name(id=receiver), attr="append"), args=[value], keywords=[])
630
+ ) if receiver == name:
631
+ return value
632
+ case _:
633
+ return None
634
+
635
+
636
+ def _locals_observed(loop: ast.For, names: set[str], facts: _Facts) -> bool:
637
+ inside = frozenset(ast.walk(loop))
638
+ return any(
639
+ read not in inside and facts.bindings(name, read) == facts.bindings(name, loop)
640
+ for name in names
641
+ for read in facts.provenance.reads.get(name, ())
642
+ )
643
+
644
+
645
+ def _collection_observed(loop: ast.For, initializer: ast.stmt, owner: ast.AST, name: str, facts: _Facts) -> bool:
646
+ bindings = facts.bindings(name, loop)
647
+ if len(bindings) != 1 or facts.context.parents.get(bindings[0]) is not initializer:
648
+ return True
649
+ inside = frozenset(ast.walk(loop))
650
+ return any(
651
+ read not in inside
652
+ and facts.bindings(name, read) == bindings
653
+ and not (isinstance(facts.context.parents.get(read), ast.Return) and facts.provenance.scope(read) is owner)
654
+ for read in facts.provenance.reads.get(name, ())
655
+ )