sarj-python-lint 1.4.0__tar.gz → 1.6.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.4.0 → sarj_python_lint-1.6.0}/PKG-INFO +1 -1
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/pyproject.toml +1 -1
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/_registry.py +2 -0
- sarj_python_lint-1.6.0/src/sarj_python_lint/rules/require_explicit_psycopg_transaction.py +373 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/stepdown.py +469 -120
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/.gitignore +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/LICENSE +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/README.md +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/_analysis_session.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/_file_context.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/_python_target.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/benchmark.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/json_boundary.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/_ast_position.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/_dict_key_views.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/_imports.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/_nominal_project.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/_set_fields.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/_test_composition.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_monotonic_for_elapsed_time.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_regex_fullmatch.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/shared_mutable_pydantic_factory.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
- {sarj_python_lint-1.4.0 → sarj_python_lint-1.6.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.6.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/
|
|
@@ -149,6 +149,7 @@ from sarj_python_lint.rules.redundant_module_docstring import RedundantModuleDoc
|
|
|
149
149
|
from sarj_python_lint.rules.repeated_kwarg_heavy_call_in_test import RepeatedKwargHeavyCallInTest
|
|
150
150
|
from sarj_python_lint.rules.repeated_static_call_cases import RepeatedStaticCallCases
|
|
151
151
|
from sarj_python_lint.rules.repeated_test_composition import RepeatedTestComposition
|
|
152
|
+
from sarj_python_lint.rules.require_explicit_psycopg_transaction import RequireExplicitPsycopgTransaction
|
|
152
153
|
from sarj_python_lint.rules.require_keyword_only_swap_prone_params import (
|
|
153
154
|
RequireKeywordOnlySwapProneParams,
|
|
154
155
|
)
|
|
@@ -186,6 +187,7 @@ if TYPE_CHECKING:
|
|
|
186
187
|
REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
187
188
|
{
|
|
188
189
|
NoSwallowedAsyncioCancellation.id: NoSwallowedAsyncioCancellation,
|
|
190
|
+
RequireExplicitPsycopgTransaction.id: RequireExplicitPsycopgTransaction,
|
|
189
191
|
AsyncMockCallWithoutAwaitAssertion.id: AsyncMockCallWithoutAwaitAssertion,
|
|
190
192
|
ComplexPostgresQueryRequiresArchitectureReview.id: ComplexPostgresQueryRequiresArchitectureReview,
|
|
191
193
|
ExcessiveCommentary.id: ExcessiveCommentary,
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import PurePosixPath
|
|
6
|
+
from typing import TYPE_CHECKING, ClassVar, Literal, final, override
|
|
7
|
+
|
|
8
|
+
import sqlglot
|
|
9
|
+
from sqlglot import exp
|
|
10
|
+
from sqlglot.errors import SqlglotError
|
|
11
|
+
|
|
12
|
+
from sarj_python_lint.rule_base import (
|
|
13
|
+
AutofixPolicy,
|
|
14
|
+
Diagnostic,
|
|
15
|
+
ExampleFile,
|
|
16
|
+
ExampleOutcome,
|
|
17
|
+
Rule,
|
|
18
|
+
RuleCategory,
|
|
19
|
+
RuleDocumentation,
|
|
20
|
+
RuleExample,
|
|
21
|
+
Severity,
|
|
22
|
+
is_suppressed,
|
|
23
|
+
)
|
|
24
|
+
from sarj_python_lint.rules._imports import ImportIndex
|
|
25
|
+
from sarj_python_lint.rules._paths import is_test_path, is_test_support_path
|
|
26
|
+
from sarj_python_lint.rules._sql import sql_string_value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from collections.abc import Iterator
|
|
31
|
+
|
|
32
|
+
from sarj_python_lint._file_context import PythonFileContext
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
type _Function = ast.FunctionDef | ast.AsyncFunctionDef
|
|
36
|
+
type _Kind = Literal["pool", "connection", "cursor"]
|
|
37
|
+
_MUTATIONS = (exp.Insert, exp.Update, exp.Delete, exp.Merge)
|
|
38
|
+
_BAD = 'from psycopg_pool import AsyncConnectionPool\nasync def save(pool: AsyncConnectionPool):\n async with pool.connection() as conn:\n await conn.execute("SELECT id FROM items FOR UPDATE")\n await conn.execute("UPDATE items SET n = 1")\n'
|
|
39
|
+
_GOOD = _BAD.replace("as conn:", "as conn, conn.transaction():")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@final
|
|
43
|
+
class RequireExplicitPsycopgTransaction(Rule):
|
|
44
|
+
id = "require-explicit-psycopg-transaction"
|
|
45
|
+
code = "SARJ462"
|
|
46
|
+
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
47
|
+
default_level=Severity.WARNING,
|
|
48
|
+
summary="Make Psycopg locking reads and related writes explicitly transactional.",
|
|
49
|
+
rationale="Pool checkout does not start a transaction in autocommit mode. Locks can expire before use and earlier writes can survive a later failure.",
|
|
50
|
+
remediation="Enclose the complete atomic operation in the same connection's transaction() context, or use one atomic SQL statement. Document intentionally independent writes with an exact suppression.",
|
|
51
|
+
category=RuleCategory.CORRECTNESS,
|
|
52
|
+
autofix=AutofixPolicy.NONE,
|
|
53
|
+
limitations=(
|
|
54
|
+
"Reports import-proven Psycopg connections and cursors from typed parameters or constructor-injected pools. Dynamic factories, interprocedural SQL, and untyped ownership are not inferred.",
|
|
55
|
+
"Inspects literal SQL, simple local/module constants, and psycopg.sql.SQL wrappers. Dynamic SQL and unsupported SQL syntax are excluded.",
|
|
56
|
+
"A locking SELECT followed by a write on the same connection outside transaction() is reported; a single statement containing a mutation is treated as atomic. Multiple writes on a reachable local path are a warning because independent writes can be intentional.",
|
|
57
|
+
"Loops, exception handlers, match statements, comprehensions, and nested closures are not analyzed as execution paths. Manual BEGIN/commit protocols are not inferred. Tests, support fixtures, and generated files are excluded.",
|
|
58
|
+
"The rule cannot prove isolation level, database constraints, or application-level atomicity; fault-injection integration tests remain necessary.",
|
|
59
|
+
),
|
|
60
|
+
examples=(
|
|
61
|
+
RuleExample(
|
|
62
|
+
example_id="unprotected-lock",
|
|
63
|
+
title="A pool checkout alone does not retain a lock",
|
|
64
|
+
outcome=ExampleOutcome.MATCH,
|
|
65
|
+
files=(ExampleFile.python("repository.py", _BAD),),
|
|
66
|
+
focus_path=PurePosixPath("repository.py"),
|
|
67
|
+
expected_count=1,
|
|
68
|
+
public=True,
|
|
69
|
+
),
|
|
70
|
+
RuleExample(
|
|
71
|
+
example_id="explicit-lock-transaction",
|
|
72
|
+
title="Retain locks for the atomic operation",
|
|
73
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
74
|
+
files=(ExampleFile.python("repository.py", _GOOD),),
|
|
75
|
+
focus_path=PurePosixPath("repository.py"),
|
|
76
|
+
expected_count=0,
|
|
77
|
+
public=True,
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
description = documentation.summary
|
|
82
|
+
|
|
83
|
+
@override
|
|
84
|
+
def check_context(self, context: PythonFileContext) -> list[Diagnostic]:
|
|
85
|
+
if (
|
|
86
|
+
"execute" not in context.source
|
|
87
|
+
or context.generated
|
|
88
|
+
or is_test_path(context.path)
|
|
89
|
+
or is_test_support_path(context.path)
|
|
90
|
+
):
|
|
91
|
+
return []
|
|
92
|
+
tree = context.tree
|
|
93
|
+
if tree is None:
|
|
94
|
+
return []
|
|
95
|
+
imports = ImportIndex.from_tree(tree, module_scope_only=True)
|
|
96
|
+
constants = _constants(tree.body)
|
|
97
|
+
findings: dict[int, ast.Call] = {}
|
|
98
|
+
for function, attributes in _functions(tree.body, imports):
|
|
99
|
+
bindings = attributes | _parameters(function, imports)
|
|
100
|
+
parameter_names = {
|
|
101
|
+
arg.arg for arg in (*function.args.posonlyargs, *function.args.args, *function.args.kwonlyargs)
|
|
102
|
+
}
|
|
103
|
+
analysis = _Analysis(
|
|
104
|
+
imports, {name: value for name, value in constants.items() if name not in parameter_names}, findings
|
|
105
|
+
)
|
|
106
|
+
analysis.block(function.body, _State(bindings=bindings))
|
|
107
|
+
return [
|
|
108
|
+
Diagnostic(
|
|
109
|
+
path=context.path,
|
|
110
|
+
line=node.lineno,
|
|
111
|
+
col=node.col_offset + 1,
|
|
112
|
+
code=self.code,
|
|
113
|
+
severity=Severity.WARNING,
|
|
114
|
+
message="Locking read or multiple writes without a shared explicit transaction; wrap the atomic operation in this connection's transaction() context.",
|
|
115
|
+
)
|
|
116
|
+
for node in sorted(findings.values(), key=lambda item: (item.lineno, item.col_offset))
|
|
117
|
+
if not is_suppressed(context.source_lines, node.lineno, self.code)
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class _Binding:
|
|
123
|
+
kind: _Kind
|
|
124
|
+
connection: int
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class _State:
|
|
129
|
+
bindings: dict[str, _Binding] = field(default_factory=dict)
|
|
130
|
+
writes: set[int] = field(default_factory=set)
|
|
131
|
+
locks: dict[int, ast.Call] = field(default_factory=dict)
|
|
132
|
+
transactions: set[int] = field(default_factory=set)
|
|
133
|
+
constants: dict[str, ast.expr] = field(default_factory=dict)
|
|
134
|
+
|
|
135
|
+
def copy(self) -> _State:
|
|
136
|
+
return _State(
|
|
137
|
+
bindings=self.bindings.copy(),
|
|
138
|
+
writes=self.writes.copy(),
|
|
139
|
+
locks=self.locks.copy(),
|
|
140
|
+
transactions=self.transactions.copy(),
|
|
141
|
+
constants=self.constants.copy(),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _key(node: ast.expr) -> str:
|
|
146
|
+
return ast.dump(node, include_attributes=False)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _parameters(function: _Function, imports: ImportIndex) -> dict[str, _Binding]:
|
|
150
|
+
return {
|
|
151
|
+
_key(ast.Name(id=arg.arg, ctx=ast.Load())): _Binding(kind, id(arg))
|
|
152
|
+
for arg in (*function.args.posonlyargs, *function.args.args, *function.args.kwonlyargs)
|
|
153
|
+
if (kind := _annotation(arg.annotation, imports)) is not None
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _annotation(node: ast.expr | None, imports: ImportIndex) -> _Kind | None:
|
|
158
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
159
|
+
try:
|
|
160
|
+
node = ast.parse(node.value, mode="eval").body
|
|
161
|
+
except SyntaxError:
|
|
162
|
+
return None
|
|
163
|
+
if isinstance(node, ast.Subscript):
|
|
164
|
+
node = node.value
|
|
165
|
+
if node is None:
|
|
166
|
+
return None
|
|
167
|
+
if imports.resolved_symbol(node, sources=frozenset({"psycopg_pool"})) in {"AsyncConnectionPool", "ConnectionPool"}:
|
|
168
|
+
return "pool"
|
|
169
|
+
if imports.resolved_symbol(node, sources=frozenset({"psycopg"})) in {"AsyncConnection", "Connection"}:
|
|
170
|
+
return "connection"
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _functions(statements: list[ast.stmt], imports: ImportIndex) -> Iterator[tuple[_Function, dict[str, _Binding]]]:
|
|
175
|
+
for statement in statements:
|
|
176
|
+
if isinstance(statement, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
177
|
+
yield statement, {}
|
|
178
|
+
elif isinstance(statement, ast.ClassDef):
|
|
179
|
+
attributes = _class_bindings(statement, imports)
|
|
180
|
+
for method in statement.body:
|
|
181
|
+
if isinstance(method, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
182
|
+
yield method, attributes
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _class_bindings(owner: ast.ClassDef, imports: ImportIndex) -> dict[str, _Binding]:
|
|
186
|
+
constructor = next(
|
|
187
|
+
(method for method in owner.body if isinstance(method, ast.FunctionDef) and method.name == "__init__"), None
|
|
188
|
+
)
|
|
189
|
+
if constructor is None:
|
|
190
|
+
return {}
|
|
191
|
+
parameters = _parameters(constructor, imports)
|
|
192
|
+
attributes: dict[str, _Binding] = {}
|
|
193
|
+
for assignment in constructor.body:
|
|
194
|
+
if not isinstance(assignment, ast.Assign):
|
|
195
|
+
continue
|
|
196
|
+
binding = parameters.get(_key(assignment.value))
|
|
197
|
+
if binding is None:
|
|
198
|
+
continue
|
|
199
|
+
for target in assignment.targets:
|
|
200
|
+
if isinstance(target, ast.Attribute):
|
|
201
|
+
attributes[_load_key(target)] = binding
|
|
202
|
+
return attributes
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _load_key(node: ast.expr) -> str:
|
|
206
|
+
if isinstance(node, ast.Name):
|
|
207
|
+
return _key(ast.Name(id=node.id, ctx=ast.Load()))
|
|
208
|
+
if isinstance(node, ast.Attribute):
|
|
209
|
+
return _key(ast.Attribute(value=node.value, attr=node.attr, ctx=ast.Load()))
|
|
210
|
+
return _key(node)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _constants(statements: list[ast.stmt]) -> dict[str, ast.expr]:
|
|
214
|
+
values: dict[str, ast.expr] = {}
|
|
215
|
+
for statement in statements:
|
|
216
|
+
if isinstance(statement, ast.Assign):
|
|
217
|
+
for target in statement.targets:
|
|
218
|
+
if isinstance(target, ast.Name):
|
|
219
|
+
values[target.id] = statement.value
|
|
220
|
+
elif isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name) and statement.value:
|
|
221
|
+
values[statement.target.id] = statement.value
|
|
222
|
+
return values
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass
|
|
226
|
+
class _Analysis:
|
|
227
|
+
imports: ImportIndex
|
|
228
|
+
constants: dict[str, ast.expr]
|
|
229
|
+
findings: dict[int, ast.Call]
|
|
230
|
+
|
|
231
|
+
def block(self, statements: list[ast.stmt], state: _State) -> _State | None:
|
|
232
|
+
for statement in statements:
|
|
233
|
+
match statement:
|
|
234
|
+
case ast.With() | ast.AsyncWith():
|
|
235
|
+
result = self.with_block(statement, state)
|
|
236
|
+
case ast.If():
|
|
237
|
+
result = self.branches(statement, state)
|
|
238
|
+
case ast.Return() | ast.Raise():
|
|
239
|
+
self.executions(statement, state)
|
|
240
|
+
return None
|
|
241
|
+
case ast.Expr() | ast.Assign() | ast.AnnAssign():
|
|
242
|
+
self.simple(statement, state)
|
|
243
|
+
result = state
|
|
244
|
+
case ast.Pass():
|
|
245
|
+
result = state
|
|
246
|
+
case _:
|
|
247
|
+
# Unsupported control flow can rebind names or terminate; do not guess across it.
|
|
248
|
+
result = _State(transactions=state.transactions.copy())
|
|
249
|
+
if result is None:
|
|
250
|
+
return None
|
|
251
|
+
state = result
|
|
252
|
+
return state
|
|
253
|
+
|
|
254
|
+
def with_block(self, statement: ast.With | ast.AsyncWith, state: _State) -> _State | None:
|
|
255
|
+
prior_transactions = state.transactions.copy()
|
|
256
|
+
for item in statement.items:
|
|
257
|
+
call = item.context_expr
|
|
258
|
+
if isinstance(call, ast.Call) and isinstance(call.func, ast.Attribute):
|
|
259
|
+
binding = state.bindings.get(_key(call.func.value))
|
|
260
|
+
if binding and call.func.attr == "transaction" and binding.kind == "connection":
|
|
261
|
+
state.transactions.add(binding.connection)
|
|
262
|
+
if item.optional_vars:
|
|
263
|
+
self.bind(item.optional_vars, call, state)
|
|
264
|
+
result = self.block(statement.body, state)
|
|
265
|
+
if result is not None:
|
|
266
|
+
result.transactions = prior_transactions
|
|
267
|
+
return result
|
|
268
|
+
|
|
269
|
+
def branches(self, statement: ast.If, state: _State) -> _State | None:
|
|
270
|
+
if isinstance(statement.test, ast.Constant):
|
|
271
|
+
return self.block(statement.body if statement.test.value else statement.orelse, state)
|
|
272
|
+
left = self.block(statement.body, state.copy())
|
|
273
|
+
right = self.block(statement.orelse, state.copy())
|
|
274
|
+
if left is None:
|
|
275
|
+
return right
|
|
276
|
+
if right is None:
|
|
277
|
+
return left
|
|
278
|
+
return _State(
|
|
279
|
+
bindings={key: value for key, value in left.bindings.items() if right.bindings.get(key) == value},
|
|
280
|
+
writes=left.writes | right.writes,
|
|
281
|
+
locks=left.locks | right.locks,
|
|
282
|
+
transactions=left.transactions & right.transactions,
|
|
283
|
+
constants={key: value for key, value in left.constants.items() if right.constants.get(key) is value},
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def simple(self, statement: ast.stmt, state: _State) -> None:
|
|
287
|
+
self.executions(statement, state)
|
|
288
|
+
if isinstance(statement, ast.Assign):
|
|
289
|
+
for target in statement.targets:
|
|
290
|
+
self.bind(target, statement.value, state)
|
|
291
|
+
elif isinstance(statement, ast.AnnAssign) and statement.value:
|
|
292
|
+
self.bind(statement.target, statement.value, state)
|
|
293
|
+
|
|
294
|
+
def bind(self, target: ast.expr, value: ast.expr, state: _State) -> None:
|
|
295
|
+
binding = state.bindings.get(_key(value))
|
|
296
|
+
if isinstance(value, ast.Call) and isinstance(value.func, ast.Attribute):
|
|
297
|
+
parent = state.bindings.get(_key(value.func.value))
|
|
298
|
+
if parent and parent.kind == "pool" and value.func.attr == "connection":
|
|
299
|
+
binding = _Binding("connection", id(value))
|
|
300
|
+
elif parent and parent.kind == "connection" and value.func.attr == "cursor":
|
|
301
|
+
binding = _Binding("cursor", parent.connection)
|
|
302
|
+
key = _load_key(target)
|
|
303
|
+
state.bindings.pop(key, None)
|
|
304
|
+
if binding:
|
|
305
|
+
state.bindings[key] = binding
|
|
306
|
+
if isinstance(target, ast.Name):
|
|
307
|
+
state.constants[target.id] = value
|
|
308
|
+
|
|
309
|
+
def executions(self, statement: ast.stmt, state: _State) -> None:
|
|
310
|
+
for node in _eager_nodes(statement):
|
|
311
|
+
if (
|
|
312
|
+
not isinstance(node, ast.Call)
|
|
313
|
+
or not isinstance(node.func, ast.Attribute)
|
|
314
|
+
or node.func.attr not in {"execute", "executemany"}
|
|
315
|
+
):
|
|
316
|
+
continue
|
|
317
|
+
binding = state.bindings.get(_key(node.func.value))
|
|
318
|
+
if not binding or binding.kind == "pool" or binding.connection in state.transactions or not node.args:
|
|
319
|
+
continue
|
|
320
|
+
sql = self.sql(node.args[0], state, frozenset())
|
|
321
|
+
if sql is None:
|
|
322
|
+
continue
|
|
323
|
+
effects = _sql_effects(sql)
|
|
324
|
+
if effects.mutation and (binding.connection in state.writes or binding.connection in state.locks):
|
|
325
|
+
self.findings.setdefault(binding.connection, state.locks.get(binding.connection, node))
|
|
326
|
+
if effects.locking and not effects.mutation:
|
|
327
|
+
state.locks.setdefault(binding.connection, node)
|
|
328
|
+
if effects.mutation:
|
|
329
|
+
state.writes.add(binding.connection)
|
|
330
|
+
|
|
331
|
+
def sql(self, node: ast.expr, state: _State, seen: frozenset[str]) -> str | None:
|
|
332
|
+
if isinstance(node, ast.Name):
|
|
333
|
+
if node.id in seen:
|
|
334
|
+
return None
|
|
335
|
+
value = state.constants.get(node.id, self.constants.get(node.id))
|
|
336
|
+
return self.sql(value, state, seen | {node.id}) if value else None
|
|
337
|
+
if isinstance(node, ast.Call):
|
|
338
|
+
if self.imports.resolves(node.func, sources=frozenset({"psycopg.sql"}), symbol="SQL") and node.args:
|
|
339
|
+
return self.sql(node.args[0], state, seen)
|
|
340
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == "format":
|
|
341
|
+
return self.sql(node.func.value, state, seen)
|
|
342
|
+
return None
|
|
343
|
+
return sql_string_value(node)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _eager_nodes(node: ast.AST) -> Iterator[ast.AST]:
|
|
347
|
+
if isinstance(
|
|
348
|
+
node, ast.Lambda | ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp | ast.IfExp | ast.BoolOp
|
|
349
|
+
):
|
|
350
|
+
return
|
|
351
|
+
yield node
|
|
352
|
+
for child in ast.iter_child_nodes(node):
|
|
353
|
+
yield from _eager_nodes(child)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@dataclass(frozen=True)
|
|
357
|
+
class _SqlEffects:
|
|
358
|
+
mutation: bool
|
|
359
|
+
locking: bool
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _sql_effects(sql: str) -> _SqlEffects:
|
|
363
|
+
try:
|
|
364
|
+
queries = sqlglot.parse(sql, read="postgres")
|
|
365
|
+
except SqlglotError:
|
|
366
|
+
return _SqlEffects(mutation=False, locking=False)
|
|
367
|
+
mutation = False
|
|
368
|
+
locking = False
|
|
369
|
+
for query in queries:
|
|
370
|
+
if query is not None:
|
|
371
|
+
mutation = mutation or query.find(*_MUTATIONS) is not None
|
|
372
|
+
locking = locking or query.find(exp.Lock) is not None
|
|
373
|
+
return _SqlEffects(mutation=mutation, locking=locking)
|