sarj-python-lint 1.7.8__tar.gz → 1.8.2__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.7.8 → sarj_python_lint-1.8.2}/PKG-INFO +2 -2
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/pyproject.toml +2 -2
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +124 -74
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +58 -57
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +7 -3
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +10 -62
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/.gitignore +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/LICENSE +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/README.md +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/_analysis_session.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/_file_context.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/_python_target.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/benchmark.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/json_boundary.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_ast_position.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_contract_annotation.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_dict_key_views.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_fastapi.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_imports.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_nominal_project.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_project_index.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_registry.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_resource_provenance.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_set_fields.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_test_composition.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/async_cleanup_registered_synchronously.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/discourage_nullable_constructor_parameters.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_monotonic_for_elapsed_time.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_regex_fullmatch.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/require_explicit_contract_implementation.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/require_explicit_psycopg_transaction.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/require_explicit_service_contract.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/require_public_dependency_contract.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/shared_mutable_pydantic_factory.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/subprocess_kill_without_reap.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
- {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/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.8.2
|
|
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/
|
|
@@ -19,7 +19,7 @@ Classifier: Topic :: Software Development :: Quality Assurance
|
|
|
19
19
|
Requires-Python: >=3.14
|
|
20
20
|
Requires-Dist: packaging==26.3
|
|
21
21
|
Requires-Dist: ruff>=0.16.9
|
|
22
|
-
Requires-Dist: sarj-rule-contracts==0.
|
|
22
|
+
Requires-Dist: sarj-rule-contracts==0.2.0
|
|
23
23
|
Requires-Dist: sqlglot==30.19.0
|
|
24
24
|
Requires-Dist: typer==0.27.2
|
|
25
25
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sarj-python-lint"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.8.2"
|
|
4
4
|
description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{ name = "sarj-ai" }]
|
|
@@ -15,7 +15,7 @@ classifiers = [
|
|
|
15
15
|
"Programming Language :: Python :: 3.14",
|
|
16
16
|
"Topic :: Software Development :: Quality Assurance",
|
|
17
17
|
]
|
|
18
|
-
dependencies = ["sarj-rule-contracts==0.
|
|
18
|
+
dependencies = ["sarj-rule-contracts==0.2.0", "packaging==26.3", "ruff>=0.16.9", "sqlglot==30.19.0", "typer==0.27.2"]
|
|
19
19
|
|
|
20
20
|
[project.scripts]
|
|
21
21
|
sarj-python-lint = "sarj_python_lint.__main__:main"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
|
+
from enum import Enum, auto
|
|
4
5
|
from pathlib import PurePosixPath
|
|
5
6
|
from types import MappingProxyType
|
|
6
7
|
from typing import TYPE_CHECKING, ClassVar, Final, final, override
|
|
@@ -65,12 +66,18 @@ type _TestFunction = ast.FunctionDef | ast.AsyncFunctionDef
|
|
|
65
66
|
type _Reference = tuple[str, ...]
|
|
66
67
|
|
|
67
68
|
|
|
69
|
+
class _Evidence(Enum):
|
|
70
|
+
POSITIVE = auto()
|
|
71
|
+
INSUFFICIENT = auto()
|
|
72
|
+
UNRESOLVED = auto()
|
|
73
|
+
|
|
74
|
+
|
|
68
75
|
@final
|
|
69
76
|
class AsyncMockCallWithoutAwaitAssertion(Rule):
|
|
70
77
|
id = "async-mock-call-without-await-assertion"
|
|
71
78
|
code = "SARJ456"
|
|
72
79
|
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
73
|
-
default_level=Severity.
|
|
80
|
+
default_level=Severity.WARNING,
|
|
74
81
|
summary="Pair positive AsyncMock call assertions with evidence that the mock was awaited.",
|
|
75
82
|
rationale=(
|
|
76
83
|
"Calling an AsyncMock records a call before its coroutine is awaited. A positive call assertion can "
|
|
@@ -87,33 +94,50 @@ class AsyncMockCallWithoutAwaitAssertion(Rule):
|
|
|
87
94
|
"Only direct statements in collected test functions and methods are inspected; fixtures, helpers, nested blocks, and generated files are excluded.",
|
|
88
95
|
"Requires a fresh local unittest.mock.AsyncMock constructor with unshadowed imports; aliases, reassignment, reset, and ambiguous configuration are excluded.",
|
|
89
96
|
"Only the mock itself and unchanged immediate children of unspecced, unwrapped mocks are inferred; spec children, return values, and deeper chains are excluded.",
|
|
90
|
-
"Positive call
|
|
91
|
-
"
|
|
97
|
+
"Positive call evidence includes direct assertion methods and literal integer or boolean call-state checks; unresolved call expectations are excluded.",
|
|
98
|
+
"Await evidence excludes provably vacuous checks, including zero or nonnegative counts and empty expected await sequences; unresolved expectations are accepted without inferring their values.",
|
|
99
|
+
"Conjunctions need one accepted await branch; disjunctions need accepted evidence for the same mock in every branch. Argument equality and assertion strength are not compared.",
|
|
100
|
+
"Compound negation and custom expression semantics remain unresolved; no variable values or fixture graphs are inferred.",
|
|
92
101
|
"Custom assertion helpers and intentional scheduling contracts may require a reasoned local suppression.",
|
|
93
102
|
),
|
|
94
103
|
examples=(
|
|
95
104
|
RuleExample(
|
|
96
|
-
example_id="
|
|
97
|
-
title="A
|
|
105
|
+
example_id="call-only-async-mock",
|
|
106
|
+
title="A call assertion does not prove awaiting",
|
|
98
107
|
outcome=ExampleOutcome.MATCH,
|
|
99
108
|
files=(
|
|
100
109
|
ExampleFile.python(
|
|
101
110
|
"tests/test_delivery.py",
|
|
102
|
-
"from unittest.mock import AsyncMock\n\nasync def test_delivery():\n send = AsyncMock()\n await deliver(send)\n
|
|
111
|
+
"from unittest.mock import AsyncMock\n\nasync def test_delivery():\n send = AsyncMock()\n await deliver(send)\n send.assert_called_once_with('item')\n",
|
|
103
112
|
),
|
|
104
113
|
),
|
|
105
114
|
focus_path=PurePosixPath("tests/test_delivery.py"),
|
|
106
115
|
expected_count=1,
|
|
107
|
-
public=
|
|
116
|
+
public=True,
|
|
108
117
|
),
|
|
109
118
|
RuleExample(
|
|
110
|
-
example_id="call-
|
|
111
|
-
title="
|
|
119
|
+
example_id="call-and-await-async-mock",
|
|
120
|
+
title="Call and await assertions protect different contracts",
|
|
121
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
122
|
+
files=(
|
|
123
|
+
ExampleFile.python(
|
|
124
|
+
"tests/test_delivery.py",
|
|
125
|
+
"from unittest.mock import AsyncMock\n\nasync def test_delivery():\n send = AsyncMock()\n await deliver(send)\n send.assert_called_once_with('item')\n send.assert_awaited_once_with('item')\n",
|
|
126
|
+
),
|
|
127
|
+
),
|
|
128
|
+
focus_path=PurePosixPath("tests/test_delivery.py"),
|
|
129
|
+
expected_count=0,
|
|
130
|
+
public=True,
|
|
131
|
+
),
|
|
132
|
+
RuleExample(
|
|
133
|
+
example_id="vacuous-await-count",
|
|
134
|
+
scenario="await-expectation-boundaries",
|
|
135
|
+
title="A nonnegative await count does not prove execution",
|
|
112
136
|
outcome=ExampleOutcome.MATCH,
|
|
113
137
|
files=(
|
|
114
138
|
ExampleFile.python(
|
|
115
139
|
"tests/test_delivery.py",
|
|
116
|
-
"from unittest.mock import AsyncMock\n\nasync def test_delivery():\n send = AsyncMock()\n await deliver(send)\n send.
|
|
140
|
+
"from unittest.mock import AsyncMock\n\nasync def test_delivery():\n send = AsyncMock()\n await deliver(send)\n assert send.call_count == 1\n assert send.await_count >= 0\n",
|
|
117
141
|
),
|
|
118
142
|
),
|
|
119
143
|
focus_path=PurePosixPath("tests/test_delivery.py"),
|
|
@@ -121,13 +145,14 @@ class AsyncMockCallWithoutAwaitAssertion(Rule):
|
|
|
121
145
|
public=True,
|
|
122
146
|
),
|
|
123
147
|
RuleExample(
|
|
124
|
-
example_id="
|
|
125
|
-
|
|
148
|
+
example_id="dynamic-expected-awaits",
|
|
149
|
+
scenario="await-expectation-boundaries",
|
|
150
|
+
title="Unresolved await expectations retain their acceptance",
|
|
126
151
|
outcome=ExampleOutcome.NO_MATCH,
|
|
127
152
|
files=(
|
|
128
153
|
ExampleFile.python(
|
|
129
154
|
"tests/test_delivery.py",
|
|
130
|
-
"from unittest.mock import AsyncMock\n\nasync def test_delivery():\n send = AsyncMock()\n await deliver(send)\n send.
|
|
155
|
+
"from unittest.mock import AsyncMock\n\nasync def test_delivery(expected):\n send = AsyncMock()\n await deliver(send)\n send.assert_called_once()\n send.assert_has_awaits(expected)\n",
|
|
131
156
|
),
|
|
132
157
|
),
|
|
133
158
|
focus_path=PurePosixPath("tests/test_delivery.py"),
|
|
@@ -166,7 +191,7 @@ class AsyncMockCallWithoutAwaitAssertion(Rule):
|
|
|
166
191
|
line=call.lineno,
|
|
167
192
|
col=call.col_offset + 1,
|
|
168
193
|
code=self.code,
|
|
169
|
-
severity=Severity.
|
|
194
|
+
severity=Severity.WARNING,
|
|
170
195
|
message="AsyncMock call assertions do not prove awaiting; keep this assertion and add an await assertion",
|
|
171
196
|
)
|
|
172
197
|
for test in _collected_tests(tree.body)
|
|
@@ -231,7 +256,7 @@ def _call_oracles(statement: ast.stmt) -> Iterator[tuple[_Reference, ast.Call |
|
|
|
231
256
|
):
|
|
232
257
|
yield _reference(receiver), call
|
|
233
258
|
case ast.Assert(test=condition):
|
|
234
|
-
for reference in sorted(
|
|
259
|
+
for reference in sorted(_asserted_states(condition, _CALL_STATE)):
|
|
235
260
|
oracle = next(
|
|
236
261
|
node
|
|
237
262
|
for node in walk_ast(condition)
|
|
@@ -304,49 +329,60 @@ def _await_oracles(statements: list[ast.stmt]) -> set[_Reference]:
|
|
|
304
329
|
case ast.Expr(value=ast.Call(func=ast.Attribute(value=receiver, attr=method)) as call) if (
|
|
305
330
|
method in _AWAIT_ASSERTIONS
|
|
306
331
|
):
|
|
307
|
-
if method != "assert_has_awaits" or
|
|
332
|
+
if method != "assert_has_awaits" or _calls_evidence(call) is not _Evidence.INSUFFICIENT:
|
|
308
333
|
covered.add(_reference(receiver))
|
|
309
334
|
case ast.Assert(test=condition):
|
|
310
|
-
covered.update(
|
|
335
|
+
covered.update(_asserted_states(condition, _AWAIT_STATE))
|
|
311
336
|
case _:
|
|
312
337
|
pass
|
|
313
338
|
return covered
|
|
314
339
|
|
|
315
340
|
|
|
316
|
-
def
|
|
341
|
+
def _calls_evidence(call: ast.Call) -> _Evidence:
|
|
317
342
|
if call.args:
|
|
318
|
-
return
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
)
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
(
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
343
|
+
return _sequence_evidence(call.args[0])
|
|
344
|
+
for keyword in call.keywords:
|
|
345
|
+
if keyword.arg == "calls":
|
|
346
|
+
return _sequence_evidence(keyword.value)
|
|
347
|
+
return _Evidence.UNRESOLVED
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _sequence_evidence(node: ast.expr) -> _Evidence:
|
|
351
|
+
if isinstance(node, (ast.List, ast.Tuple)):
|
|
352
|
+
if not node.elts:
|
|
353
|
+
return _Evidence.INSUFFICIENT
|
|
354
|
+
if any(not isinstance(item, ast.Starred) for item in node.elts):
|
|
355
|
+
return _Evidence.POSITIVE
|
|
356
|
+
return _Evidence.UNRESOLVED
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _asserted_states(condition: ast.expr, states: frozenset[str]) -> set[_Reference]:
|
|
360
|
+
match condition:
|
|
361
|
+
case ast.UnaryOp(op=ast.Not(), operand=ast.UnaryOp(op=ast.Not(), operand=operand)):
|
|
362
|
+
return _asserted_states(operand, states)
|
|
363
|
+
case ast.UnaryOp(op=ast.Not(), operand=ast.Compare(left=left, ops=[operator], comparators=[right])):
|
|
364
|
+
inverse = _negated_operator(operator)
|
|
365
|
+
return _comparison_states(left, inverse, right, states) if inverse is not None else set()
|
|
366
|
+
case ast.UnaryOp(op=ast.Not(), operand=ast.Attribute(attr=attribute)) if attribute in states:
|
|
367
|
+
return set()
|
|
368
|
+
case ast.BoolOp(op=operator, values=values):
|
|
369
|
+
branches = [_asserted_states(value, states) for value in values]
|
|
370
|
+
return (
|
|
371
|
+
branches[0].union(*branches[1:])
|
|
372
|
+
if isinstance(operator, ast.And)
|
|
373
|
+
else branches[0].intersection(*branches[1:])
|
|
374
|
+
)
|
|
375
|
+
case ast.Attribute(value=receiver, attr=attribute) if attribute in states:
|
|
376
|
+
return {_reference(receiver)}
|
|
377
|
+
case ast.Compare(left=left, ops=operators, comparators=rights):
|
|
378
|
+
result: set[_Reference] = set()
|
|
379
|
+
for first, operator, second in zip((left, *rights[:-1]), operators, rights, strict=True):
|
|
380
|
+
result.update(_comparison_states(first, operator, second, states))
|
|
381
|
+
return result
|
|
382
|
+
case _:
|
|
383
|
+
# Preserve acceptance of unresolved await-state assertions, such as
|
|
384
|
+
# len(mock.await_args_list) == expected, without value propagation.
|
|
385
|
+
return _unresolved_await_states(condition, states)
|
|
350
386
|
|
|
351
387
|
|
|
352
388
|
def _negated_operator(operator: ast.cmpop) -> ast.cmpop | None:
|
|
@@ -364,6 +400,18 @@ def _negated_operator(operator: ast.cmpop) -> ast.cmpop | None:
|
|
|
364
400
|
return factory() if factory is not None else None
|
|
365
401
|
|
|
366
402
|
|
|
403
|
+
def _comparison_states(left: ast.expr, operator: ast.cmpop, right: ast.expr, states: frozenset[str]) -> set[_Reference]:
|
|
404
|
+
result: set[_Reference] = set()
|
|
405
|
+
for node, comparison, expected in ((left, operator, right), (right, _reversed_operator(operator), left)):
|
|
406
|
+
if isinstance(node, ast.Attribute) and node.attr in states:
|
|
407
|
+
evidence = _state_evidence(node.attr, comparison, expected)
|
|
408
|
+
if evidence is _Evidence.POSITIVE or (states == _AWAIT_STATE and evidence is _Evidence.UNRESOLVED):
|
|
409
|
+
result.add(_reference(node.value))
|
|
410
|
+
else:
|
|
411
|
+
result.update(_unresolved_await_states(node, states))
|
|
412
|
+
return result
|
|
413
|
+
|
|
414
|
+
|
|
367
415
|
def _reversed_operator(operator: ast.cmpop) -> ast.cmpop:
|
|
368
416
|
match operator:
|
|
369
417
|
case ast.Lt():
|
|
@@ -378,32 +426,38 @@ def _reversed_operator(operator: ast.cmpop) -> ast.cmpop:
|
|
|
378
426
|
return operator
|
|
379
427
|
|
|
380
428
|
|
|
381
|
-
def
|
|
382
|
-
if
|
|
429
|
+
def _unresolved_await_states(node: ast.expr, states: frozenset[str]) -> set[_Reference]:
|
|
430
|
+
if states != _AWAIT_STATE:
|
|
383
431
|
return set()
|
|
432
|
+
return {
|
|
433
|
+
_reference(child.value) for child in walk_ast(node) if isinstance(child, ast.Attribute) and child.attr in states
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _state_evidence(attribute: str, operator: ast.cmpop, expected: ast.expr) -> _Evidence:
|
|
438
|
+
positive: bool
|
|
384
439
|
if (
|
|
385
|
-
|
|
386
|
-
and isinstance(
|
|
387
|
-
and
|
|
388
|
-
and left.attr in {"args", "kwargs"}
|
|
440
|
+
attribute in {"call_count", "await_count"}
|
|
441
|
+
and isinstance(expected, ast.Constant)
|
|
442
|
+
and isinstance(expected.value, int)
|
|
389
443
|
):
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
if left.attr in {"call_count", "await_count"} and isinstance(right, ast.Constant) and type(right.value) is int:
|
|
395
|
-
positive = _proves_positive_count(operator, right.value)
|
|
396
|
-
elif left.attr == "called" and isinstance(right, ast.Constant):
|
|
397
|
-
positive = (isinstance(operator, (ast.Eq, ast.Is)) and right.value is True) or (
|
|
398
|
-
isinstance(operator, (ast.NotEq, ast.IsNot)) and right.value is False
|
|
444
|
+
positive = _proves_positive_count(operator, expected.value)
|
|
445
|
+
elif attribute == "called" and isinstance(expected, ast.Constant) and isinstance(expected.value, bool):
|
|
446
|
+
positive = (isinstance(operator, (ast.Eq, ast.Is)) and expected.value) or (
|
|
447
|
+
isinstance(operator, (ast.NotEq, ast.IsNot)) and not expected.value
|
|
399
448
|
)
|
|
400
|
-
elif
|
|
449
|
+
elif attribute == "await_args" and isinstance(expected, ast.Constant) and expected.value is None:
|
|
401
450
|
positive = isinstance(operator, (ast.NotEq, ast.IsNot))
|
|
402
|
-
elif
|
|
403
|
-
|
|
404
|
-
|
|
451
|
+
elif attribute == "await_args_list" and isinstance(operator, (ast.Eq, ast.NotEq)):
|
|
452
|
+
sequence = _sequence_evidence(expected)
|
|
453
|
+
if sequence is _Evidence.UNRESOLVED:
|
|
454
|
+
return sequence
|
|
455
|
+
positive = (isinstance(operator, ast.Eq) and sequence is _Evidence.POSITIVE) or (
|
|
456
|
+
isinstance(operator, ast.NotEq) and isinstance(expected, ast.List) and sequence is _Evidence.INSUFFICIENT
|
|
405
457
|
)
|
|
406
|
-
|
|
458
|
+
else:
|
|
459
|
+
return _Evidence.UNRESOLVED
|
|
460
|
+
return _Evidence.POSITIVE if positive else _Evidence.INSUFFICIENT
|
|
407
461
|
|
|
408
462
|
|
|
409
463
|
def _proves_positive_count(operator: ast.cmpop, value: int) -> bool:
|
|
@@ -418,10 +472,6 @@ def _proves_positive_count(operator: ast.cmpop, value: int) -> bool:
|
|
|
418
472
|
return False
|
|
419
473
|
|
|
420
474
|
|
|
421
|
-
def _nonempty_sequence(node: ast.expr) -> bool:
|
|
422
|
-
return isinstance(node, (ast.List, ast.Tuple)) and any(not isinstance(item, ast.Starred) for item in node.elts)
|
|
423
|
-
|
|
424
|
-
|
|
425
475
|
def _invalidates_reference(node: ast.AST, reference: _Reference, binding: ast.Name) -> bool:
|
|
426
476
|
match node:
|
|
427
477
|
case ast.Name(id=name, ctx=ast.Store() | ast.Del()):
|
|
@@ -243,26 +243,12 @@ def _expression_args(node: exp.Expr, key: str) -> tuple[exp.Expr, ...]:
|
|
|
243
243
|
return tuple(child for child in node.iter_expressions() if child.arg_key == key)
|
|
244
244
|
|
|
245
245
|
|
|
246
|
-
def _root_selects(query: exp.Query) -> tuple[exp.Select, ...]:
|
|
247
|
-
from sqlglot import exp # ruff: ignore[import-outside-top-level] -- keep parser startup off unrelated lint runs
|
|
248
|
-
|
|
249
|
-
if isinstance(query, exp.Select):
|
|
250
|
-
return (query,)
|
|
251
|
-
if not isinstance(query, exp.SetOperation):
|
|
252
|
-
return ()
|
|
253
|
-
branches: list[exp.Select] = []
|
|
254
|
-
for key in ("this", "expression"):
|
|
255
|
-
if isinstance((branch := _expression_arg(query, key)), exp.Query):
|
|
256
|
-
branches.extend(_root_selects(branch))
|
|
257
|
-
return tuple(branches)
|
|
258
|
-
|
|
259
|
-
|
|
260
246
|
def _derived_query(node: exp.Expr | None) -> exp.Query | None:
|
|
261
247
|
from sqlglot import exp # ruff: ignore[import-outside-top-level] -- keep parser startup off unrelated lint runs
|
|
262
248
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
return None
|
|
249
|
+
while isinstance(node, (exp.Lateral, exp.Paren, exp.Subquery)):
|
|
250
|
+
node = _expression_arg(node, "this")
|
|
251
|
+
return node if isinstance(node, exp.Query) else None
|
|
266
252
|
|
|
267
253
|
|
|
268
254
|
@final
|
|
@@ -278,18 +264,21 @@ class ComplexPostgresQueryRequiresArchitectureReview(Rule):
|
|
|
278
264
|
"cost, a bad data model, or the right datastore."
|
|
279
265
|
),
|
|
280
266
|
remediation=(
|
|
281
|
-
"
|
|
282
|
-
"
|
|
283
|
-
"
|
|
284
|
-
"
|
|
267
|
+
"Prefer ClickHouse for reporting, historical analysis, and broad aggregations, with measured performance "
|
|
268
|
+
"and an explicit freshness contract. For transactional work, simplify the query or document why "
|
|
269
|
+
"PostgreSQL is required, with query bounds and production-like EXPLAIN evidence. Review whether the "
|
|
270
|
+
"schema exposes the operational fact directly; preserve atomic coordination and do not mechanically "
|
|
271
|
+
"replace database joins with application joins. Do not add reporting-specific indexes without "
|
|
272
|
+
"evaluating ClickHouse."
|
|
285
273
|
),
|
|
286
274
|
category=RuleCategory.ARCHITECTURE,
|
|
287
275
|
autofix=AutofixPolicy.NONE,
|
|
288
276
|
limitations=(
|
|
289
|
-
"Only statically recoverable SQL passed to execute, executemany, fetch, fetchrow, fetchval, or prepare in non-test modules with explicit PostgreSQL imports is analyzed
|
|
277
|
+
"Only statically recoverable SQL passed to execute, executemany, fetch, fetchrow, fetchval, or prepare in non-test modules with explicit PostgreSQL imports is analyzed, regardless of module filename.",
|
|
290
278
|
"Execution receivers must use a conventional database name; custom wrappers and dynamically obtained receivers abstain.",
|
|
291
279
|
"One direct, unambiguous simple-name binding in the same lexical scope is followed; standalone constants, branches, aliases, attributes, containers, wildcard imports, and cross-scope flow abstain.",
|
|
292
|
-
"
|
|
280
|
+
"Review covers derived relations in every SELECT (including LATERAL, CTEs, predicates and writes), four explicit or comma joins, six query stages, three joins plus four stages, or 25 projections plus two joins.",
|
|
281
|
+
"Derived-relation review requires grouping, HAVING or windowing combined with joins or nested reads; expression density is not a separate trigger.",
|
|
293
282
|
"Projection width alone is accepted, and UNION branches are measured independently; execution plans and production cardinality remain authoritative.",
|
|
294
283
|
"ClickHouse and BigQuery syntax is excluded per query, including in mixed-backend modules.",
|
|
295
284
|
"The rule does not infer optimizer behavior, materialization, performance, data-model quality, or datastore placement.",
|
|
@@ -405,9 +394,11 @@ class ComplexPostgresQueryRequiresArchitectureReview(Rule):
|
|
|
405
394
|
code=self.code,
|
|
406
395
|
severity=Severity.WARNING,
|
|
407
396
|
message=(
|
|
408
|
-
f"{signal}
|
|
409
|
-
"
|
|
410
|
-
"
|
|
397
|
+
f"Complex PostgreSQL query: {signal}. Architecture review required;{guidance} "
|
|
398
|
+
"Prefer ClickHouse for reporting, historical analysis, and broad aggregations. "
|
|
399
|
+
"For transactional work, simplify the query or document why PostgreSQL is required, "
|
|
400
|
+
"with query bounds and EXPLAIN evidence. Do not add reporting-specific indexes without "
|
|
401
|
+
"evaluating ClickHouse. Syntax alone does not establish runtime cost or datastore placement."
|
|
411
402
|
),
|
|
412
403
|
)
|
|
413
404
|
)
|
|
@@ -494,45 +485,40 @@ def _query_architecture_signal(text_value: str, path: Path) -> str | None:
|
|
|
494
485
|
or (is_store_module(path) and analytical_signal(sql_without_noise) is not None)
|
|
495
486
|
):
|
|
496
487
|
return None
|
|
497
|
-
return _parse_signal(text_value
|
|
488
|
+
return _parse_signal(text_value)
|
|
498
489
|
|
|
499
490
|
|
|
500
|
-
def _parse_signal(sql: str
|
|
491
|
+
def _parse_signal(sql: str) -> str | None:
|
|
501
492
|
import sqlglot # ruff: ignore[import-outside-top-level] -- parse only SQL that passes cheap ownership and shape gates
|
|
502
493
|
from sqlglot.errors import SqlglotError # ruff: ignore[import-outside-top-level] -- paired with lazy parser import
|
|
503
494
|
|
|
504
495
|
normalized = _COMPOSABLE_HOLE.sub(_SQL_HOLE, sql)
|
|
505
496
|
try:
|
|
506
|
-
|
|
497
|
+
for statement in sqlglot.parse(normalized, read="postgres"):
|
|
498
|
+
if statement is not None and (signal := _architecture_signal(statement)) is not None:
|
|
499
|
+
return signal
|
|
507
500
|
except SqlglotError:
|
|
508
501
|
return None
|
|
509
|
-
for statement in statements:
|
|
510
|
-
if (
|
|
511
|
-
statement is not None
|
|
512
|
-
and (signal := _architecture_signal(statement, include_extended_shapes=include_extended_shapes)) is not None
|
|
513
|
-
):
|
|
514
|
-
return signal
|
|
515
502
|
return None
|
|
516
503
|
|
|
517
504
|
|
|
518
|
-
def _architecture_signal(statement: exp.Expr
|
|
505
|
+
def _architecture_signal(statement: exp.Expr) -> str | None:
|
|
519
506
|
from sqlglot import exp # ruff: ignore[import-outside-top-level] -- keep parser startup off unrelated lint runs
|
|
520
507
|
|
|
521
|
-
if not isinstance(statement, exp.Query):
|
|
508
|
+
if not isinstance(statement, (exp.Query, exp.Insert, exp.Update, exp.Delete)):
|
|
522
509
|
return None
|
|
523
510
|
maximum_joins = _maximum_query_joins(statement)
|
|
524
511
|
if maximum_joins >= _JOIN_LIMIT:
|
|
525
|
-
return f"SELECT block has {maximum_joins} JOINs (4+ explicit JOINs)"
|
|
526
|
-
if
|
|
527
|
-
|
|
528
|
-
nested_queries = sum(1 for query in statement.walk() if isinstance(query, exp.Query)) - 1
|
|
512
|
+
return f"SELECT block has {maximum_joins} JOINs (4+ explicit JOINs or comma relations)"
|
|
513
|
+
nested_queries = sum(1 for query in statement.walk() if isinstance(query, (exp.Select, exp.SetOperation)))
|
|
514
|
+
nested_queries -= isinstance(statement, (exp.Select, exp.SetOperation))
|
|
529
515
|
if (derived_signal := _derived_architecture_signal(statement)) is not None:
|
|
530
516
|
return derived_signal
|
|
531
517
|
if nested_queries >= _STAGE_LIMIT:
|
|
532
518
|
return f"Query has {nested_queries} CTE/subquery stages"
|
|
533
519
|
if maximum_joins >= _COMBINED_JOIN_LIMIT and nested_queries >= _COMBINED_STAGE_LIMIT:
|
|
534
520
|
return f"Query combines {maximum_joins} JOINs with {nested_queries} CTE/subquery stages"
|
|
535
|
-
for select in
|
|
521
|
+
for select in statement.find_all(exp.Select):
|
|
536
522
|
projections = tuple(_expression_args(select, "expressions"))
|
|
537
523
|
joins = tuple(join for join in _expression_args(select, "joins") if isinstance(join, exp.Join))
|
|
538
524
|
if len(projections) >= _WIDE_PROJECTION_LIMIT and len(joins) >= _WIDE_PROJECTION_JOIN_LIMIT:
|
|
@@ -540,20 +526,20 @@ def _architecture_signal(statement: exp.Expr, *, include_extended_shapes: bool)
|
|
|
540
526
|
return None
|
|
541
527
|
|
|
542
528
|
|
|
543
|
-
def _derived_architecture_signal(statement: exp.
|
|
529
|
+
def _derived_architecture_signal(statement: exp.Expr) -> str | None:
|
|
544
530
|
from sqlglot import exp # ruff: ignore[import-outside-top-level] -- keep parser startup off unrelated lint runs
|
|
545
531
|
|
|
546
|
-
for select in
|
|
532
|
+
for select in statement.find_all(exp.Select):
|
|
547
533
|
for join in _expression_args(select, "joins"):
|
|
548
534
|
if not isinstance(join, exp.Join):
|
|
549
535
|
continue
|
|
550
536
|
if (derived := _derived_query(_expression_arg(join, "this"))) is not None and _has_complex_body(derived):
|
|
551
|
-
return "
|
|
537
|
+
return "JOIN-derived query combines grouping, HAVING or windowing with joins or nested reads"
|
|
552
538
|
from_clause = _expression_arg(select, "from_")
|
|
553
539
|
if not isinstance(from_clause, exp.From):
|
|
554
540
|
continue
|
|
555
541
|
if (derived := _derived_query(_expression_arg(from_clause, "this"))) is not None and _has_complex_body(derived):
|
|
556
|
-
return "
|
|
542
|
+
return "FROM-derived query combines grouping, HAVING or windowing with joins or nested reads"
|
|
557
543
|
return None
|
|
558
544
|
|
|
559
545
|
|
|
@@ -561,28 +547,43 @@ def _has_complex_body(query: exp.Query) -> bool:
|
|
|
561
547
|
from sqlglot import exp # ruff: ignore[import-outside-top-level] -- keep parser startup off unrelated lint runs
|
|
562
548
|
|
|
563
549
|
if isinstance(query, exp.SetOperation):
|
|
564
|
-
return
|
|
550
|
+
return any(
|
|
551
|
+
isinstance(branch := _expression_arg(query, key), exp.Query)
|
|
552
|
+
and (_has_row_transform(branch) or _has_complex_body(branch))
|
|
553
|
+
for key in ("this", "expression")
|
|
554
|
+
)
|
|
565
555
|
if not isinstance(query, exp.Select):
|
|
566
556
|
return False
|
|
567
|
-
if
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
return True
|
|
571
|
-
from_clause = _expression_arg(query, "from_")
|
|
572
|
-
if isinstance(from_clause, exp.From) and _derived_query(_expression_arg(from_clause, "this")) is not None:
|
|
557
|
+
if _has_row_transform(query) and (
|
|
558
|
+
_expression_args(query, "joins") or any(select is not query for select in query.find_all(exp.Select))
|
|
559
|
+
):
|
|
573
560
|
return True
|
|
574
|
-
for
|
|
575
|
-
if
|
|
561
|
+
for relation in query.find_all(exp.From, exp.Join):
|
|
562
|
+
if relation.parent is not query:
|
|
563
|
+
continue
|
|
564
|
+
child = _derived_query(_expression_arg(relation, "this"))
|
|
565
|
+
if child is not None and (_has_row_transform(child) or _has_complex_body(child)):
|
|
576
566
|
return True
|
|
577
567
|
return False
|
|
578
568
|
|
|
579
569
|
|
|
580
|
-
def
|
|
570
|
+
def _has_row_transform(query: exp.Query) -> bool:
|
|
571
|
+
from sqlglot import exp # ruff: ignore[import-outside-top-level] -- keep parser startup off unrelated lint runs
|
|
572
|
+
|
|
573
|
+
select = _derived_query(query)
|
|
574
|
+
return isinstance(select, exp.Select) and (
|
|
575
|
+
_expression_arg(select, "group") is not None
|
|
576
|
+
or _expression_arg(select, "having") is not None
|
|
577
|
+
or any(window.parent_select is select for window in select.find_all(exp.Window))
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _maximum_query_joins(statement: exp.Expr) -> int:
|
|
581
582
|
from sqlglot import exp # ruff: ignore[import-outside-top-level] -- keep parser startup off unrelated lint runs
|
|
582
583
|
|
|
583
584
|
return max(
|
|
584
585
|
(
|
|
585
|
-
sum(isinstance(join, exp.Join)
|
|
586
|
+
sum(isinstance(join, exp.Join) for join in _expression_args(select, "joins"))
|
|
586
587
|
for select in statement.find_all(exp.Select)
|
|
587
588
|
),
|
|
588
589
|
default=0,
|
|
@@ -159,8 +159,10 @@ class NoAnalyticalAggregationInPostgresStore(Rule):
|
|
|
159
159
|
"consistent operational aggregates remain valid PostgreSQL work."
|
|
160
160
|
),
|
|
161
161
|
remediation=(
|
|
162
|
-
"Review the query bounds and execution plan.
|
|
163
|
-
"
|
|
162
|
+
"Review the query bounds and execution plan. Prefer ClickHouse for reporting, historical analysis, "
|
|
163
|
+
"and broad aggregations, with measured performance and an explicit freshness contract. "
|
|
164
|
+
"For PostgreSQL, document why the aggregate must remain transactional, with EXPLAIN evidence. "
|
|
165
|
+
"Do not add reporting-specific indexes without evaluating ClickHouse."
|
|
164
166
|
),
|
|
165
167
|
category=RuleCategory.ARCHITECTURE,
|
|
166
168
|
autofix=AutofixPolicy.NONE,
|
|
@@ -247,7 +249,9 @@ class NoAnalyticalAggregationInPostgresStore(Rule):
|
|
|
247
249
|
severity=Severity.WARNING,
|
|
248
250
|
message=(
|
|
249
251
|
f"Possible analytical PostgreSQL query ({signal}); review its bounds and execution plan. "
|
|
250
|
-
"
|
|
252
|
+
"Prefer ClickHouse for reporting, historical analysis, and broad aggregations. "
|
|
253
|
+
"For PostgreSQL, document why this aggregate must remain transactional, with EXPLAIN evidence. "
|
|
254
|
+
"Do not add reporting-specific indexes without evaluating ClickHouse."
|
|
251
255
|
),
|
|
252
256
|
)
|
|
253
257
|
)
|