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.
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/PKG-INFO +1 -1
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/pyproject.toml +1 -1
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_registry.py +6 -0
- sarj_python_lint-1.48.0/src/sarj_python_lint/rules/_store_writes.py +319 -0
- sarj_python_lint-1.48.0/src/sarj_python_lint/rules/prefer_bulk_upsert.py +139 -0
- sarj_python_lint-1.48.0/src/sarj_python_lint/rules/prefer_psycopg_executemany.py +139 -0
- sarj_python_lint-1.48.0/src/sarj_python_lint/rules/store_field_update_reuses_upsert.py +240 -0
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/.gitignore +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/LICENSE +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/README.md +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/_analysis_session.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/_file_context.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/_python_target.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/_source.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/benchmark.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/interpreter_argv.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/json_boundary.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_ast_position.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_closed_helpers.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_contract_annotation.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_copy_on_write.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_data_union_discriminator.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_dict_key_views.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_exports.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_imports.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_inert_model_annotation.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_inherited_contract.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_local_source.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_namespace_access.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_native_namespace_capability.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_nominal_project.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_pydantic_field_contract.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_resolved_operation_signature.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_resource_provenance.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_set_fields.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_static_json_alias.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_test_composition.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_test_provenance.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/_type_guard_symbols.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/async_cleanup_registered_synchronously.py +0 -0
- {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
- {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
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/duplicate_adjacent_exception_handler.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/duplicate_closed_pure_helper.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
- {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
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_async_function_cache.py +0 -0
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_broad_keyword_capture.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {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
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_contradictory_type_guard.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_database_programmability.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_injected_module_loader.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_input_model_mutation.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_interpreter_source_arguments.py +0 -0
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_invalid_pydantic_bypass.py +0 -0
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_loguru_exc_info.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_loguru_printf_format.py +0 -0
- {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
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_nullable_dependency_fallback.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_partial_type_is.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {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
- {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
- {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
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_test_method_grafting.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {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
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_unused_copy_result.py +0 -0
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
- {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
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_declarative_batch_validation.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_discriminated_model_union.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {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
- {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
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_native_string_check.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_pytest_fixture_injection.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_regex_fullmatch.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_required_constructor_parameters.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/redundant_test_constructor_forwarder.py +0 -0
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/repeated_unimplemented_test_contract.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/require_explicit_contract_implementation.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/require_explicit_psycopg_transaction.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/require_explicit_service_contract.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/require_injectable_retry_sleep.py +0 -0
- {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
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/require_precise_factory_signature.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/require_public_dependency_contract.py +0 -0
- {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
- {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
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/shared_mutable_pydantic_factory.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {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
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/subprocess_kill_without_reap.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/test_without_explicit_oracle.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
- {sarj_python_lint-1.47.0 → sarj_python_lint-1.48.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
- {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.
|
|
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/
|
|
@@ -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
|