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