sarj-python-lint 1.47.0__tar.gz → 1.48.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 (220) hide show
  1. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_registry.py +6 -0
  4. sarj_python_lint-1.48.0/src/sarj_python_lint/rules/_store_writes.py +319 -0
  5. sarj_python_lint-1.48.0/src/sarj_python_lint/rules/prefer_bulk_upsert.py +139 -0
  6. sarj_python_lint-1.48.0/src/sarj_python_lint/rules/prefer_psycopg_executemany.py +139 -0
  7. sarj_python_lint-1.48.0/src/sarj_python_lint/rules/store_field_update_reuses_upsert.py +240 -0
  8. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +13 -2
  9. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/.gitignore +0 -0
  10. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/LICENSE +0 -0
  11. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/README.md +0 -0
  12. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/__init__.py +0 -0
  13. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/__main__.py +0 -0
  14. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/_analysis_session.py +0 -0
  15. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/_file_context.py +0 -0
  16. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/_filesystem.py +0 -0
  17. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/_python_target.py +0 -0
  18. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  19. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/_secret_names.py +0 -0
  20. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/_source.py +0 -0
  21. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/_version.py +0 -0
  22. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/benchmark.py +0 -0
  23. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/interpreter_argv.py +0 -0
  24. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/json_boundary.py +0 -0
  25. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/py.typed +0 -0
  26. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/ratchet.py +0 -0
  27. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rule_base.py +0 -0
  28. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  29. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
  30. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  31. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_ast_position.py +0 -0
  32. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_closed_helpers.py +0 -0
  33. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  34. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_contract_annotation.py +0 -0
  35. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_copy_on_write.py +0 -0
  36. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_data_union_discriminator.py +0 -0
  37. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_dict_key_views.py +0 -0
  38. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  39. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_exports.py +0 -0
  40. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  41. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  42. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  43. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  44. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_inert_model_annotation.py +0 -0
  45. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_inherited_contract.py +0 -0
  46. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_local_source.py +0 -0
  47. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  48. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_namespace_access.py +0 -0
  49. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_native_namespace_capability.py +0 -0
  50. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_nominal_project.py +0 -0
  51. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  52. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
  53. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  54. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_pydantic_field_contract.py +0 -0
  55. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  56. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_resolved_operation_signature.py +0 -0
  57. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_resource_provenance.py +0 -0
  58. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_set_fields.py +0 -0
  59. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  60. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_static_json_alias.py +0 -0
  61. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  62. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  63. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_test_composition.py +0 -0
  64. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_test_provenance.py +0 -0
  65. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_type_guard_symbols.py +0 -0
  66. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/async_cleanup_registered_synchronously.py +0 -0
  67. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +0 -0
  68. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  69. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  70. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  71. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  72. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/duplicate_adjacent_exception_handler.py +0 -0
  73. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/duplicate_closed_pure_helper.py +0 -0
  74. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  75. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  76. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
  77. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  78. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  79. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  80. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  81. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
  82. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  83. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  84. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
  85. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_async_function_cache.py +0 -0
  86. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_before_validator_input_mutation.py +0 -0
  87. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_broad_keyword_capture.py +0 -0
  88. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  89. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_conflicting_pydantic_alias_validation.py +0 -0
  90. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  91. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_contradictory_type_guard.py +0 -0
  92. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  93. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  94. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_database_programmability.py +0 -0
  95. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
  96. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  97. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  98. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  99. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  100. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  101. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  102. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
  103. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_fixed_sleep_in_loop.py +0 -0
  104. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  105. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  106. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  107. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_impossible_pydantic_field_bounds.py +0 -0
  108. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_injected_module_loader.py +0 -0
  109. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_input_model_mutation.py +0 -0
  110. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_interpreter_source_arguments.py +0 -0
  111. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
  112. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_invalid_pydantic_bypass.py +0 -0
  113. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_invalid_pydantic_validator_return.py +0 -0
  114. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_loguru_exc_info.py +0 -0
  115. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_loguru_printf_format.py +0 -0
  116. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_mocked_pydantic_value_object.py +0 -0
  117. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  118. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_nullable_dependency_fallback.py +0 -0
  119. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  120. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_partial_type_is.py +0 -0
  121. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  122. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  123. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  124. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  125. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  126. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  127. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  128. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  129. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  130. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  131. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  132. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  133. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  134. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_shallow_container_type_guard.py +0 -0
  135. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_shallow_record_type_guard.py +0 -0
  136. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  137. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
  138. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  139. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_test_method_grafting.py +0 -0
  140. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  141. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  142. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_unknown_pydantic_validator_field.py +0 -0
  143. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  144. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_unused_copy_result.py +0 -0
  145. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_unused_underscored_keyword_parameter.py +0 -0
  146. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
  147. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  148. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  149. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  150. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  151. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  152. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_autospec_for_callable_mock.py +0 -0
  153. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  154. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  155. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  156. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_declarative_batch_validation.py +0 -0
  157. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_discriminated_model_union.py +0 -0
  158. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  159. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  160. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
  161. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  162. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  163. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
  164. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  165. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  166. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  167. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
  168. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_monotonic_for_elapsed_time.py +0 -0
  169. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  170. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_native_string_check.py +0 -0
  171. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  172. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  173. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  174. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
  175. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_pytest_fixture_injection.py +0 -0
  176. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_regex_fullmatch.py +0 -0
  177. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_required_constructor_parameters.py +0 -0
  178. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  179. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  180. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  181. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  182. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  183. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  184. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  185. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  186. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  187. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  188. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  189. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  190. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  191. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  192. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/redundant_test_constructor_forwarder.py +0 -0
  193. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  194. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  195. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
  196. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/repeated_unimplemented_test_contract.py +0 -0
  197. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/require_explicit_contract_implementation.py +0 -0
  198. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/require_explicit_psycopg_transaction.py +0 -0
  199. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/require_explicit_service_contract.py +0 -0
  200. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/require_injectable_retry_sleep.py +0 -0
  201. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  202. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  203. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  204. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/require_precise_factory_signature.py +0 -0
  205. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/require_public_dependency_contract.py +0 -0
  206. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  207. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
  208. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  209. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  210. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/shared_mutable_pydantic_factory.py +0 -0
  211. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  212. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  213. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/subprocess_kill_without_reap.py +0 -0
  214. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/test_without_explicit_oracle.py +0 -0
  215. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  216. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  217. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  218. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  219. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  220. {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.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.47.0
3
+ Version: 1.48.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.47.0"
3
+ version = "1.48.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" }]
@@ -221,14 +221,20 @@ from sarj_python_lint.rules.no_invalid_pydantic_validator_return import NoInvali
221
221
  from sarj_python_lint.rules.no_partial_type_is import NoPartialTypeIs
222
222
  from sarj_python_lint.rules.no_shallow_record_type_guard import NoShallowRecordTypeGuard
223
223
  from sarj_python_lint.rules.no_unknown_pydantic_validator_field import NoUnknownPydanticValidatorField
224
+ from sarj_python_lint.rules.prefer_bulk_upsert import PreferBulkUpsert
224
225
  from sarj_python_lint.rules.prefer_discriminated_model_union import PreferDiscriminatedModelUnion
226
+ from sarj_python_lint.rules.prefer_psycopg_executemany import PreferPsycopgExecutemany
225
227
  from sarj_python_lint.rules.redundant_test_constructor_forwarder import RedundantTestConstructorForwarder
226
228
  from sarj_python_lint.rules.repeated_unimplemented_test_contract import RepeatedUnimplementedTestContract
229
+ from sarj_python_lint.rules.store_field_update_reuses_upsert import StoreFieldUpdateReusesUpsert
227
230
  from sarj_python_lint.rules.test_without_explicit_oracle import NoExplicitTestOracle
228
231
 
229
232
 
230
233
  REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
231
234
  {
235
+ PreferBulkUpsert.id: PreferBulkUpsert,
236
+ PreferPsycopgExecutemany.id: PreferPsycopgExecutemany,
237
+ StoreFieldUpdateReusesUpsert.id: StoreFieldUpdateReusesUpsert,
232
238
  NoExplicitTestOracle.id: NoExplicitTestOracle,
233
239
  PreferDiscriminatedModelUnion.id: PreferDiscriminatedModelUnion,
234
240
  NoInvalidPydanticBypass.id: NoInvalidPydanticBypass,
@@ -0,0 +1,319 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from types import MappingProxyType
5
+ from typing import TYPE_CHECKING
6
+
7
+ import sqlglot
8
+ from sqlglot import exp
9
+ from sqlglot.errors import SqlglotError
10
+
11
+ from sarj_python_lint.rules._annotation_semantics import AnnotationSemantics
12
+ from sarj_python_lint.rules._ast_index import walk
13
+ from sarj_python_lint.rules._paths import is_generated
14
+ from sarj_python_lint.rules._project_index import ProjectIndexSet
15
+ from sarj_python_lint.rules._resource_provenance import ResourceProvenance
16
+
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Iterator
20
+
21
+ from sarj_python_lint._file_context import PythonFileContext
22
+ from sarj_python_lint.rules._imports import ImportIndex
23
+
24
+
25
+ type Function = ast.FunctionDef | ast.AsyncFunctionDef
26
+ _COLLECTIONS = frozenset(
27
+ {
28
+ "collections.abc.Collection",
29
+ "collections.abc.Iterable",
30
+ "collections.abc.Sequence",
31
+ "typing.Collection",
32
+ "typing.Iterable",
33
+ "typing.Sequence",
34
+ "typing.List",
35
+ }
36
+ )
37
+ _NEUTRAL_DECORATORS = frozenset({"abc.abstractmethod", "typing.override", "typing_extensions.override"})
38
+
39
+
40
+ def method_parameters(method: Function) -> list[ast.arg] | None:
41
+ args = method.args
42
+ positional = [*args.posonlyargs, *args.args]
43
+ if not positional or positional[0].arg != "self":
44
+ return None
45
+ if args.vararg or args.kwarg or args.defaults or any(args.kw_defaults):
46
+ return None
47
+ return [*positional[1:], *args.kwonlyargs]
48
+
49
+
50
+ def methods(owner: ast.ClassDef) -> dict[str, Function]:
51
+ result: dict[str, Function] = {}
52
+ ambiguous: set[str] = set()
53
+ for node in owner.body:
54
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
55
+ if node.name in result:
56
+ ambiguous.add(node.name)
57
+ result[node.name] = node
58
+ return {name: method for name, method in result.items() if name not in ambiguous}
59
+
60
+
61
+ def upsert_pair(owner: ast.ClassDef, imports: ImportIndex) -> tuple[Function, Function] | None:
62
+ declared = methods(owner)
63
+ single, bulk = declared.get("upsert"), declared.get("upsert_many")
64
+ if (
65
+ single is None
66
+ or bulk is None
67
+ or isinstance(single, ast.AsyncFunctionDef) != isinstance(bulk, ast.AsyncFunctionDef)
68
+ ):
69
+ return None
70
+ if any(
71
+ imports.resolved_qualified_name(decorator) not in _NEUTRAL_DECORATORS
72
+ for method in (single, bulk)
73
+ for decorator in method.decorator_list
74
+ ):
75
+ return None
76
+ one, many = method_parameters(single), method_parameters(bulk)
77
+ if one is None or many is None or len(one) != 1 or len(many) != 1:
78
+ return None
79
+ item = collection_item(many[0].annotation, imports)
80
+ result = collection_item(bulk.returns, imports)
81
+ if (
82
+ item is None
83
+ or result is None
84
+ or not same_type(one[0].annotation, item)
85
+ or not same_type(single.returns, result)
86
+ ):
87
+ return None
88
+ return single, bulk
89
+
90
+
91
+ def collection_item(annotation: ast.expr | None, imports: ImportIndex) -> ast.expr | None:
92
+ annotation = AnnotationSemantics(imports, MappingProxyType({})).parse(annotation)
93
+ if not isinstance(annotation, ast.Subscript):
94
+ return None
95
+ container = annotation.value
96
+ if isinstance(container, ast.Name) and container.id == "list" and imports.builtin_is_unshadowed("list"):
97
+ return annotation.slice
98
+ if imports.resolved_qualified_name(container) in _COLLECTIONS:
99
+ return annotation.slice
100
+ return None
101
+
102
+
103
+ def same_type(left: ast.expr | None, right: ast.expr | None) -> bool:
104
+ return (
105
+ left is not None
106
+ and right is not None
107
+ and ast.dump(left, include_attributes=False) == ast.dump(right, include_attributes=False)
108
+ )
109
+
110
+
111
+ def own_nodes(node: ast.AST) -> Iterator[ast.AST]:
112
+ pending = [node]
113
+ while pending:
114
+ current = pending.pop()
115
+ yield current
116
+ pending.extend(
117
+ child
118
+ for child in reversed(list(ast.iter_child_nodes(current)))
119
+ if not isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.Lambda)
120
+ )
121
+
122
+
123
+ def discarded_call(loop: ast.For) -> tuple[ast.Call, bool] | None:
124
+ if loop.orelse or len(loop.body) != 1 or not isinstance(loop.body[0], ast.Expr):
125
+ return None
126
+ if (
127
+ isinstance(loop.iter, ast.List | ast.Tuple | ast.Set)
128
+ and len(loop.iter.elts) <= 1
129
+ and not any(isinstance(item, ast.Starred) for item in loop.iter.elts)
130
+ ):
131
+ return None
132
+ value = loop.body[0].value
133
+ awaited = isinstance(value, ast.Await)
134
+ value = value.value if awaited else value
135
+ return (value, awaited) if isinstance(value, ast.Call) else None
136
+
137
+
138
+ def depends_on_target(expression: ast.expr, target: ast.expr) -> bool:
139
+ names = {node.id for node in walk(target) if isinstance(node, ast.Name)}
140
+ return any(isinstance(node, ast.Name) and node.id in names for node in own_nodes(expression))
141
+
142
+
143
+ class StoreOwners:
144
+ def __init__(self, context: PythonFileContext) -> None:
145
+ self.context = context
146
+ self.provenance = ResourceProvenance(context)
147
+ self.project = context.session.project
148
+
149
+ def receiver(self, expression: ast.expr, at: ast.AST) -> tuple[ast.ClassDef, ImportIndex] | None:
150
+ if isinstance(expression, ast.Name):
151
+ binding = self.provenance.binding(expression.id, at)
152
+ if isinstance(binding, ast.arg) and binding.annotation is not None:
153
+ return self.annotation(binding.annotation)
154
+ return None
155
+ if (
156
+ not isinstance(expression, ast.Attribute)
157
+ or not isinstance(expression.value, ast.Name)
158
+ or expression.value.id != "self"
159
+ ):
160
+ return None
161
+ if not isinstance(self.provenance.binding("self", at), ast.arg):
162
+ return None
163
+ annotation = self._injected_annotation(expression.attr, at)
164
+ return self.annotation(annotation) if annotation is not None else None
165
+
166
+ def _injected_annotation(self, attribute: str, at: ast.AST) -> ast.expr | None:
167
+ parent = self.context.parents.get(at)
168
+ while parent is not None and not isinstance(parent, ast.ClassDef):
169
+ parent = self.context.parents.get(parent)
170
+ if parent is None:
171
+ return None
172
+ constructor = methods(parent).get("__init__")
173
+ if constructor is None:
174
+ return None
175
+ assignments = [
176
+ node
177
+ for method in methods(parent).values()
178
+ for node in own_nodes(method)
179
+ if _writes_self_attribute(node, attribute)
180
+ ]
181
+ if len(assignments) != 1:
182
+ return None
183
+ for statement in constructor.body:
184
+ if (
185
+ not isinstance(statement, ast.Assign)
186
+ or not isinstance(statement.value, ast.Name)
187
+ or assignments[0] not in statement.targets
188
+ ):
189
+ continue
190
+ return _injected_parameter(constructor, statement.value, self.provenance)
191
+ return None
192
+
193
+ def annotation(self, expression: ast.expr | None) -> tuple[ast.ClassDef, ImportIndex] | None:
194
+ original = expression
195
+ expression = AnnotationSemantics(self.context.module_imports, MappingProxyType({})).parse(expression)
196
+ if expression is None or self.context.tree is None:
197
+ return None
198
+ root = expression.value if isinstance(expression, ast.Attribute) else expression
199
+ if (
200
+ not isinstance(root, ast.Name)
201
+ or original is None
202
+ or not isinstance(self.provenance.binding(root.id, original), ast.ClassDef | ast.alias)
203
+ ):
204
+ return None
205
+ if root.id in self.provenance.mutated:
206
+ return None
207
+ if isinstance(expression, ast.Name):
208
+ declaration = _declared_class(self.context.tree, expression.id)
209
+ if declaration is not None:
210
+ return declaration, self.context.module_imports
211
+ return self._imported_annotation(expression)
212
+
213
+ def _imported_annotation(self, expression: ast.expr) -> tuple[ast.ClassDef, ImportIndex] | None:
214
+ if self.project is None or self.context.tree is None:
215
+ return None
216
+ unit = self.project.unit_or_source(self.context.path, self.context.source, self.context.tree)
217
+ symbol = ProjectIndexSet.resolve(unit, expression) if unit is not None else None
218
+ owner = self.project.source_unit(symbol.module) if symbol is not None else None
219
+ if symbol is None or owner is None or owner.tree is None or is_generated(owner.path, owner.source):
220
+ return None
221
+ declaration = _declared_class(owner.tree, symbol.name)
222
+ if declaration is None:
223
+ return None
224
+ from sarj_python_lint._file_context import PythonFileContext # ruff: ignore[import-outside-top-level] -- defer to avoid the rule registry's context import cycle.
225
+
226
+ definition = PythonFileContext(owner.path, owner.source, self.context.session)
227
+ provenance = ResourceProvenance(definition)
228
+ if provenance.binding(symbol.name, declaration) is not declaration or symbol.name in provenance.mutated:
229
+ return None
230
+ return declaration, definition.module_imports
231
+
232
+
233
+ def _writes_self_attribute(node: ast.AST, attribute: str) -> bool:
234
+ return (
235
+ isinstance(node, ast.Attribute)
236
+ and node.attr == attribute
237
+ and isinstance(node.value, ast.Name)
238
+ and node.value.id == "self"
239
+ and isinstance(node.ctx, ast.Store | ast.Del)
240
+ )
241
+
242
+
243
+ def _injected_parameter(constructor: Function, value: ast.Name, provenance: ResourceProvenance) -> ast.expr | None:
244
+ parameter = next(
245
+ (
246
+ arg
247
+ for arg in (*constructor.args.posonlyargs, *constructor.args.args, *constructor.args.kwonlyargs)
248
+ if arg.arg == value.id
249
+ ),
250
+ None,
251
+ )
252
+ if parameter is None or provenance.binding(parameter.arg, value) is not parameter:
253
+ return None
254
+ return parameter.annotation
255
+
256
+
257
+ def _declared_class(tree: ast.Module, name: str) -> ast.ClassDef | None:
258
+ candidates = [node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == name]
259
+ return candidates[0] if len(candidates) == 1 and not candidates[0].decorator_list else None
260
+
261
+
262
+ def sql_query(node: ast.expr, imports: ImportIndex) -> exp.Expr | None:
263
+ if (
264
+ isinstance(node, ast.Call)
265
+ and imports.resolved_qualified_name(node.func) == "psycopg.sql.SQL"
266
+ and len(node.args) == 1
267
+ and not node.keywords
268
+ ):
269
+ node = node.args[0]
270
+ if not isinstance(node, ast.Constant) or not isinstance(node.value, str):
271
+ return None
272
+ try:
273
+ statements = sqlglot.parse(node.value, read="postgres")
274
+ except SqlglotError:
275
+ return None
276
+ return statements[0] if len(statements) == 1 else None
277
+
278
+
279
+ def executed_queries(method: Function, imports: ImportIndex) -> list[exp.Expr]:
280
+ queries: list[exp.Expr] = []
281
+ calls = [
282
+ node
283
+ for statement in method.body
284
+ if not isinstance(statement, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef)
285
+ for node in own_nodes(statement)
286
+ if isinstance(node, ast.Call)
287
+ and isinstance(node.func, ast.Attribute)
288
+ and node.func.attr in {"execute", "executemany"}
289
+ ]
290
+ for call in calls:
291
+ if not call.args or (query := sql_query(call.args[0], imports)) is None:
292
+ return []
293
+ queries.append(query)
294
+ return queries
295
+
296
+
297
+ def sql_arg(node: exp.Expr, key: str) -> exp.Expr | None:
298
+ return next((child for child in node.iter_expressions() if child.arg_key == key), None)
299
+
300
+
301
+ def sql_args(node: exp.Expr, key: str) -> tuple[exp.Expr, ...]:
302
+ return tuple(child for child in node.iter_expressions() if child.arg_key == key)
303
+
304
+
305
+ def sql_identifier(node: exp.Expr | None) -> str | None:
306
+ if isinstance(node, exp.Column):
307
+ node = sql_arg(node, "this")
308
+ if not isinstance(node, exp.Identifier):
309
+ return None
310
+ return node.name if node.quoted else node.name.lower()
311
+
312
+
313
+ def sql_table(node: exp.Expr | None) -> tuple[str | None, ...] | None:
314
+ if not isinstance(node, exp.Table) or sql_arg(node, "alias") is not None:
315
+ return None
316
+ return tuple(
317
+ sql_identifier(value) if (value := sql_arg(node, key)) is not None else None
318
+ for key in ("catalog", "db", "this")
319
+ )
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import PurePosixPath
5
+ from typing import TYPE_CHECKING, ClassVar, final, override
6
+
7
+ from sarj_python_lint.rule_base import (
8
+ AutofixPolicy,
9
+ Diagnostic,
10
+ ExampleFile,
11
+ ExampleOutcome,
12
+ ProjectRule,
13
+ RuleCategory,
14
+ RuleDocumentation,
15
+ RuleExample,
16
+ Severity,
17
+ is_suppressed,
18
+ )
19
+ from sarj_python_lint.rules._store_writes import (
20
+ StoreOwners,
21
+ depends_on_target,
22
+ discarded_call,
23
+ method_parameters,
24
+ upsert_pair,
25
+ )
26
+
27
+
28
+ if TYPE_CHECKING:
29
+ from sarj_python_lint._file_context import PythonFileContext
30
+
31
+
32
+ _CONTRACT = "class UserStore:\n async def upsert(self, user: UserInput) -> User:\n ...\n async def upsert_many(self, users: list[UserInput]) -> list[User]:\n ...\n"
33
+
34
+
35
+ @final
36
+ class PreferBulkUpsert(ProjectRule):
37
+ id = "prefer-bulk-upsert"
38
+ code = "SARJ516"
39
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
40
+ default_level=Severity.WARNING,
41
+ summary="Use a declared compatible upsert_many instead of discarded serial upserts.",
42
+ rationale="A loop of independent singleton writes repeats database calls while an existing bulk command centralizes conflict handling and batch execution.",
43
+ remediation="Collect commands and call upsert_many in bounded batches after reviewing duplicate keys, failure atomicity, ordering, and tenant scope. Preserve deliberately per-row transactions or effects with an exact suppression.",
44
+ category=RuleCategory.PERFORMANCE,
45
+ autofix=AutofixPolicy.NONE,
46
+ limitations=(
47
+ "Only a synchronous for loop containing one discarded upsert call is considered. Its receiver must resolve to a typed parameter or a constructor-injected attribute with source-declared compatible upsert/upsert_many methods.",
48
+ "Signatures must have one required command argument and matching scalar/collection input and output types. Annotated first-party imports use the existing project index; unresolved, inherited, overloaded, decorated, rebound and dynamic contracts are excluded.",
49
+ "Result-dependent loops, comprehensions, async streams, loop else clauses, extra statements and per-row exception or transaction blocks are excluded. A batch can change duplicate-key and failure semantics, so this remains an advisory without an autofix.",
50
+ "Maintained tests are checked; generated/vendor sources are excluded. A bulk method is not invented when none is declared.",
51
+ ),
52
+ examples=(
53
+ RuleExample(
54
+ example_id="serial-upserts",
55
+ title="Collect writes for an available bulk command",
56
+ outcome=ExampleOutcome.MATCH,
57
+ files=(
58
+ ExampleFile.python(
59
+ "app/service.py",
60
+ _CONTRACT
61
+ + "async def save(store: UserStore, users: list[UserInput]):\n for user in users:\n await store.upsert(user)\n",
62
+ ),
63
+ ),
64
+ focus_path=PurePosixPath("app/service.py"),
65
+ expected_count=1,
66
+ public=True,
67
+ ),
68
+ RuleExample(
69
+ example_id="bulk-upsert",
70
+ title="Use the existing bulk command",
71
+ outcome=ExampleOutcome.NO_MATCH,
72
+ files=(
73
+ ExampleFile.python(
74
+ "app/service.py",
75
+ _CONTRACT
76
+ + "async def save(store: UserStore, users: list[UserInput]):\n await store.upsert_many(users)\n",
77
+ ),
78
+ ),
79
+ focus_path=PurePosixPath("app/service.py"),
80
+ expected_count=0,
81
+ public=True,
82
+ ),
83
+ ),
84
+ )
85
+ description = documentation.summary
86
+
87
+ @override
88
+ def check_context(self, context: PythonFileContext) -> list[Diagnostic]:
89
+ if context.generated or "upsert" not in context.symbol_source or context.tree is None:
90
+ return []
91
+ owners = StoreOwners(context)
92
+ if owners.provenance.dynamic_namespace:
93
+ return []
94
+ findings: list[Diagnostic] = []
95
+ for loop in context.nodes(ast.For):
96
+ call = _bulk_candidate(loop, owners)
97
+ if call is None:
98
+ continue
99
+ if is_suppressed(context.source_lines, call.lineno, self.code):
100
+ continue
101
+ findings.append(
102
+ Diagnostic(
103
+ path=context.path,
104
+ line=call.lineno,
105
+ col=call.col_offset + 1,
106
+ code=self.code,
107
+ severity=Severity.WARNING,
108
+ message="This discarded serial upsert has a declared compatible `upsert_many`; collect commands and use bounded bulk writes after preserving duplicate-key, atomicity, and tenant semantics.",
109
+ )
110
+ )
111
+ return findings
112
+
113
+
114
+ def _bulk_candidate(loop: ast.For, owners: StoreOwners) -> ast.Call | None:
115
+ candidate = discarded_call(loop)
116
+ if candidate is None:
117
+ return None
118
+ call, awaited = candidate
119
+ if not isinstance(call.func, ast.Attribute) or call.func.attr != "upsert":
120
+ return None
121
+ owner = owners.receiver(call.func.value, call)
122
+ pair = upsert_pair(*owner) if owner is not None else None
123
+ if pair is None or isinstance(pair[0], ast.AsyncFunctionDef) != awaited:
124
+ return None
125
+ parameters = method_parameters(pair[0])
126
+ if parameters is None:
127
+ return None
128
+ argument = _command_argument(call, parameters[0].arg)
129
+ if argument is None or isinstance(argument, ast.Starred) or not depends_on_target(argument, loop.target):
130
+ return None
131
+ return call
132
+
133
+
134
+ def _command_argument(call: ast.Call, parameter: str) -> ast.expr | None:
135
+ if len(call.args) == 1 and not call.keywords:
136
+ return call.args[0]
137
+ if not call.args and len(call.keywords) == 1 and call.keywords[0].arg == parameter:
138
+ return call.keywords[0].value
139
+ return None
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import PurePosixPath
5
+ from typing import TYPE_CHECKING, ClassVar, final, override
6
+
7
+ from sqlglot import exp
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._annotation_semantics import AnnotationSemantics
22
+ from sarj_python_lint.rules._resource_provenance import ResourceProvenance
23
+ from sarj_python_lint.rules._store_writes import depends_on_target, discarded_call, sql_query
24
+
25
+
26
+ if TYPE_CHECKING:
27
+ from sarj_python_lint._file_context import PythonFileContext
28
+
29
+
30
+ _BAD = "from psycopg import AsyncCursor\nasync def save(cursor: AsyncCursor, rows):\n for row in rows:\n await cursor.execute('INSERT INTO users (id) VALUES (%s)', (row.id,))\n"
31
+ _EXECUTE_ARGUMENT_COUNT = 2
32
+
33
+
34
+ @final
35
+ class PreferPsycopgExecutemany(Rule):
36
+ id = "prefer-psycopg-executemany"
37
+ code = "SARJ518"
38
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
39
+ default_level=Severity.WARNING,
40
+ summary="Use Psycopg executemany for a serial loop of invariant parameterized writes without row results.",
41
+ rationale="A bare execute loop repeats the same statement through separate client calls. Psycopg executemany centralizes batch execution and can pipeline those operations.",
42
+ remediation="Prefer an existing bulk store method; otherwise send bounded parameter batches through cursor.executemany. Preserve transaction ownership, per-row failure behavior, ordering and backpressure.",
43
+ category=RuleCategory.PERFORMANCE,
44
+ autofix=AutofixPolicy.NONE,
45
+ limitations=(
46
+ "Only a synchronous for loop with one discarded execute call on an import-proven typed Psycopg Cursor or AsyncCursor parameter is inspected. The query must be one literal INSERT, UPDATE or DELETE with placeholders and loop-dependent bound parameters.",
47
+ "RETURNING, queries, dynamic SQL, extra execute options, async streams, loop else clauses, additional statements and per-row exception or transaction contexts are excluded. Connection.execute is excluded because connections do not expose the same executemany API.",
48
+ "No automatic batching or performance claim is made for a particular workload. Advisory severity preserves deliberately sequential database effects; exact suppressions document those contracts.",
49
+ "Generated/vendor sources are excluded; maintained tests are checked. Stable aliases and cursors acquired through context managers are intentionally outside the initial proof boundary.",
50
+ ),
51
+ examples=(
52
+ RuleExample(
53
+ example_id="serial-execute",
54
+ title="Batch the same parameterized statement",
55
+ outcome=ExampleOutcome.MATCH,
56
+ files=(ExampleFile.python("app/user_store.py", _BAD),),
57
+ focus_path=PurePosixPath("app/user_store.py"),
58
+ expected_count=1,
59
+ public=True,
60
+ ),
61
+ RuleExample(
62
+ example_id="batch-execute",
63
+ title="Use the cursor's batch API",
64
+ outcome=ExampleOutcome.NO_MATCH,
65
+ files=(
66
+ ExampleFile.python(
67
+ "app/user_store.py",
68
+ "from psycopg import AsyncCursor\nasync def save(cursor: AsyncCursor, rows):\n await cursor.executemany('INSERT INTO users (id) VALUES (%s)', [(row.id,) for row in rows])\n",
69
+ ),
70
+ ),
71
+ focus_path=PurePosixPath("app/user_store.py"),
72
+ expected_count=0,
73
+ public=True,
74
+ ),
75
+ ),
76
+ )
77
+ description = documentation.summary
78
+
79
+ @override
80
+ def check_context(self, context: PythonFileContext) -> list[Diagnostic]:
81
+ if context.generated or "execute" not in context.symbol_source or context.tree is None:
82
+ return []
83
+ provenance = ResourceProvenance(context)
84
+ findings: list[Diagnostic] = []
85
+ for loop in context.nodes(ast.For):
86
+ candidate = discarded_call(loop)
87
+ if candidate is None:
88
+ continue
89
+ call, awaited = candidate
90
+ if (
91
+ not isinstance(call.func, ast.Attribute)
92
+ or call.func.attr != "execute"
93
+ or not isinstance(call.func.value, ast.Name)
94
+ or len(call.args) != _EXECUTE_ARGUMENT_COUNT
95
+ or call.keywords
96
+ ):
97
+ continue
98
+ qualified = _cursor_contract(call.func.value, call, context, provenance)
99
+ if qualified not in {"psycopg.Cursor", "psycopg.AsyncCursor"} or awaited != (
100
+ qualified == "psycopg.AsyncCursor"
101
+ ):
102
+ continue
103
+ query = sql_query(call.args[0], context.imports)
104
+ if (
105
+ not isinstance(query, exp.Insert | exp.Update | exp.Delete)
106
+ or query.find(exp.Returning) is not None
107
+ or query.find(exp.Placeholder) is None
108
+ ):
109
+ continue
110
+ if not depends_on_target(call.args[1], loop.target) or is_suppressed(
111
+ context.source_lines, call.lineno, self.code
112
+ ):
113
+ continue
114
+ findings.append(
115
+ Diagnostic(
116
+ path=context.path,
117
+ line=call.lineno,
118
+ col=call.col_offset + 1,
119
+ code=self.code,
120
+ severity=Severity.WARNING,
121
+ message="This loop repeats one parameterized Psycopg write without row results; prefer the existing bulk store command or bounded `cursor.executemany()` batches, preserving transaction and failure semantics.",
122
+ )
123
+ )
124
+ return findings
125
+
126
+
127
+ def _cursor_contract(
128
+ receiver: ast.Name, call: ast.Call, context: PythonFileContext, provenance: ResourceProvenance
129
+ ) -> str | None:
130
+ binding = provenance.binding(receiver.id, call)
131
+ if not isinstance(binding, ast.arg) or context.tree is None or provenance.dynamic_namespace:
132
+ return None
133
+ annotation = AnnotationSemantics.from_tree(context.tree).parse(binding.annotation)
134
+ if isinstance(annotation, ast.Subscript):
135
+ annotation = annotation.value
136
+ root = annotation.value if isinstance(annotation, ast.Attribute) else annotation
137
+ if isinstance(root, ast.Name) and root.id in provenance.mutated:
138
+ return None
139
+ return context.module_imports.resolved_qualified_name(annotation) if annotation is not None else None