sarj-python-lint 1.27.5__tar.gz → 1.28.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.27.5 → sarj_python_lint-1.28.0}/PKG-INFO +1 -1
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/pyproject.toml +1 -1
- sarj_python_lint-1.28.0/src/sarj_python_lint/rules/_closed_helpers.py +100 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_registry.py +9 -0
- sarj_python_lint-1.28.0/src/sarj_python_lint/rules/duplicate_adjacent_exception_handler.py +213 -0
- sarj_python_lint-1.28.0/src/sarj_python_lint/rules/duplicate_closed_pure_helper.py +260 -0
- sarj_python_lint-1.28.0/src/sarj_python_lint/rules/redundant_test_constructor_forwarder.py +284 -0
- sarj_python_lint-1.28.0/src/sarj_python_lint/rules/repeated_unimplemented_test_contract.py +383 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/unused_test_factory_option.py +68 -9
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/.gitignore +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/LICENSE +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/README.md +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/_analysis_session.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/_file_context.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/_python_target.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/_source.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/benchmark.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/interpreter_argv.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/json_boundary.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_ast_position.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_contract_annotation.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_copy_on_write.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_dict_key_views.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_imports.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_local_source.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_nominal_project.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_resource_provenance.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_set_fields.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_test_composition.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/_test_provenance.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/async_cleanup_registered_synchronously.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_before_validator_input_mutation.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_broad_keyword_capture.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_database_programmability.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_fixed_sleep_in_loop.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_injected_module_loader.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_input_model_mutation.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_interpreter_source_arguments.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_mocked_pydantic_value_object.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_nullable_dependency_fallback.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_shallow_container_type_guard.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_test_method_grafting.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_unused_copy_result.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_unused_underscored_keyword_parameter.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_autospec_for_callable_mock.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_monotonic_for_elapsed_time.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_native_string_check.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_pytest_fixture_injection.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_regex_fullmatch.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_required_constructor_parameters.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_explicit_contract_implementation.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_explicit_psycopg_transaction.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_explicit_service_contract.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_injectable_retry_sleep.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_precise_factory_signature.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_public_dependency_contract.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/shared_mutable_pydantic_factory.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/subprocess_kill_without_reap.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
- {sarj_python_lint-1.27.5 → sarj_python_lint-1.28.0}/src/sarj_python_lint/rules/unused_mock_setup.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.28.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/
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from sarj_python_lint._file_context import PythonFileContext
|
|
9
|
+
from sarj_python_lint.rules._resource_provenance import ResourceProvenance
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_DYNAMIC_NAMES = frozenset(
|
|
13
|
+
{"globals", "locals", "vars", "eval", "exec", "getattr", "setattr", "delattr", "__import__", "__all__"}
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def dynamic_namespace(context: PythonFileContext) -> bool:
|
|
18
|
+
return (
|
|
19
|
+
any(node.id in _DYNAMIC_NAMES for node in context.nodes(ast.Name))
|
|
20
|
+
or any(node.attr in _DYNAMIC_NAMES | {"__dict__"} for node in context.nodes(ast.Attribute))
|
|
21
|
+
or any(node.name == "*" for node in context.nodes(ast.alias))
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def executable_body(function: ast.FunctionDef) -> list[ast.stmt]:
|
|
26
|
+
body = function.body
|
|
27
|
+
return (
|
|
28
|
+
body[1:]
|
|
29
|
+
if body
|
|
30
|
+
and isinstance(body[0], ast.Expr)
|
|
31
|
+
and isinstance(body[0].value, ast.Constant)
|
|
32
|
+
and isinstance(body[0].value.value, str)
|
|
33
|
+
else body
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def closed_references(
|
|
38
|
+
context: PythonFileContext, function: ast.FunctionDef, provenance: ResourceProvenance
|
|
39
|
+
) -> list[ast.Name] | None:
|
|
40
|
+
if (
|
|
41
|
+
context.parents.get(function) is not context.tree
|
|
42
|
+
or not function.name.startswith("_")
|
|
43
|
+
or function.name.startswith("__")
|
|
44
|
+
or function.decorator_list
|
|
45
|
+
or function.type_params
|
|
46
|
+
):
|
|
47
|
+
return None
|
|
48
|
+
if (
|
|
49
|
+
function.args.vararg is not None
|
|
50
|
+
or function.args.kwarg is not None
|
|
51
|
+
or dynamic_namespace(context)
|
|
52
|
+
or any(node.attr == function.name for node in context.nodes(ast.Attribute))
|
|
53
|
+
or any(
|
|
54
|
+
isinstance(node.value, str) and function.name in node.value.split(".")
|
|
55
|
+
for node in context.nodes(ast.Constant)
|
|
56
|
+
)
|
|
57
|
+
):
|
|
58
|
+
return None
|
|
59
|
+
references = [node for node in context.nodes(ast.Name) if node.id == function.name]
|
|
60
|
+
if not references or any(
|
|
61
|
+
not isinstance(node.ctx, ast.Load) or provenance.binding(node.id, node) is not function for node in references
|
|
62
|
+
):
|
|
63
|
+
return None
|
|
64
|
+
return references
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def direct_call(reference: ast.Name, context: PythonFileContext) -> ast.Call | None:
|
|
68
|
+
parent = context.parents.get(reference)
|
|
69
|
+
if not isinstance(parent, ast.Call) or parent.func is not reference:
|
|
70
|
+
return None
|
|
71
|
+
if any(isinstance(argument, ast.Starred) for argument in parent.args) or any(
|
|
72
|
+
keyword.arg is None for keyword in parent.keywords
|
|
73
|
+
):
|
|
74
|
+
return None
|
|
75
|
+
return parent
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def call_matches(function: ast.FunctionDef, call: ast.Call) -> bool:
|
|
79
|
+
positional = [*function.args.posonlyargs, *function.args.args]
|
|
80
|
+
keywords = {arg.arg for arg in (*function.args.args, *function.args.kwonlyargs)}
|
|
81
|
+
required = {arg.arg for arg in positional[: len(positional) - len(function.args.defaults)]}
|
|
82
|
+
required.update(
|
|
83
|
+
arg.arg
|
|
84
|
+
for arg, default in zip(function.args.kwonlyargs, function.args.kw_defaults, strict=True)
|
|
85
|
+
if default is None
|
|
86
|
+
)
|
|
87
|
+
supplied = {arg.arg for arg in positional[: len(call.args)]}
|
|
88
|
+
if len(call.args) > len(positional):
|
|
89
|
+
return False
|
|
90
|
+
for keyword in call.keywords:
|
|
91
|
+
if keyword.arg not in keywords or keyword.arg in supplied:
|
|
92
|
+
return False
|
|
93
|
+
supplied.add(keyword.arg)
|
|
94
|
+
return required <= supplied
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def stable_class(context: PythonFileContext, declaration: ast.ClassDef, provenance: ResourceProvenance) -> bool:
|
|
98
|
+
if declaration.decorator_list or declaration.keywords or context.parents.get(declaration) is not context.tree:
|
|
99
|
+
return False
|
|
100
|
+
return provenance.bindings.get(context.tree, {}).get(declaration.name) == [declaration]
|
|
@@ -207,6 +207,11 @@ if TYPE_CHECKING:
|
|
|
207
207
|
from collections.abc import Mapping
|
|
208
208
|
|
|
209
209
|
from sarj_python_lint.rule_base import Rule
|
|
210
|
+
from sarj_python_lint.rules.duplicate_adjacent_exception_handler import DuplicateAdjacentExceptionHandler
|
|
211
|
+
from sarj_python_lint.rules.duplicate_closed_pure_helper import DuplicateClosedPureHelper
|
|
212
|
+
from sarj_python_lint.rules.redundant_test_constructor_forwarder import RedundantTestConstructorForwarder
|
|
213
|
+
from sarj_python_lint.rules.repeated_unimplemented_test_contract import RepeatedUnimplementedTestContract
|
|
214
|
+
|
|
210
215
|
|
|
211
216
|
REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
212
217
|
{
|
|
@@ -348,5 +353,9 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
|
348
353
|
PreferRegexFullmatch.id: PreferRegexFullmatch,
|
|
349
354
|
PreferMonotonicForElapsedTime.id: PreferMonotonicForElapsedTime,
|
|
350
355
|
TypedErrorReasons.id: TypedErrorReasons,
|
|
356
|
+
RedundantTestConstructorForwarder.id: RedundantTestConstructorForwarder,
|
|
357
|
+
DuplicateAdjacentExceptionHandler.id: DuplicateAdjacentExceptionHandler,
|
|
358
|
+
DuplicateClosedPureHelper.id: DuplicateClosedPureHelper,
|
|
359
|
+
RepeatedUnimplementedTestContract.id: RepeatedUnimplementedTestContract,
|
|
351
360
|
}
|
|
352
361
|
)
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import builtins
|
|
5
|
+
from pathlib import PurePosixPath
|
|
6
|
+
from typing import (
|
|
7
|
+
TYPE_CHECKING,
|
|
8
|
+
ClassVar,
|
|
9
|
+
cast, # ruff: ignore[banned-api] -- vars exposes Any; the object view permits isinstance narrowing.
|
|
10
|
+
final,
|
|
11
|
+
override,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from sarj_python_lint.rule_base import (
|
|
15
|
+
AutofixPolicy,
|
|
16
|
+
Diagnostic,
|
|
17
|
+
ExampleFile,
|
|
18
|
+
ExampleOutcome,
|
|
19
|
+
ProjectRule,
|
|
20
|
+
RuleCategory,
|
|
21
|
+
RuleDocumentation,
|
|
22
|
+
RuleExample,
|
|
23
|
+
Severity,
|
|
24
|
+
is_suppressed,
|
|
25
|
+
)
|
|
26
|
+
from sarj_python_lint.rules._closed_helpers import dynamic_namespace, stable_class
|
|
27
|
+
from sarj_python_lint.rules._paths import is_generated
|
|
28
|
+
from sarj_python_lint.rules._project_index import ProjectIndexSet
|
|
29
|
+
from sarj_python_lint.rules._resource_provenance import ResourceProvenance
|
|
30
|
+
from sarj_python_lint.rules._suppression_comments import scan_comments
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from collections.abc import Mapping
|
|
35
|
+
|
|
36
|
+
from sarj_python_lint._file_context import PythonFileContext
|
|
37
|
+
from sarj_python_lint.rules._project_index import SourceUnit, SymbolRef
|
|
38
|
+
from sarj_python_lint.rules._suppression_comments import Comment
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_BUILTIN_NAMESPACE = cast("Mapping[str, object]", vars(builtins))
|
|
42
|
+
_BUILTIN_EXCEPTIONS = frozenset(
|
|
43
|
+
name for name, value in _BUILTIN_NAMESPACE.items() if isinstance(value, type) and issubclass(value, Exception)
|
|
44
|
+
)
|
|
45
|
+
_BAD = "try:\n action()\nexcept ValueError as error:\n raise RuntimeError('invalid') from error\nexcept TypeError as error:\n raise RuntimeError('invalid') from error\n"
|
|
46
|
+
_GOOD = "try:\n action()\nexcept (ValueError, TypeError) as error:\n raise RuntimeError('invalid') from error\n"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@final
|
|
50
|
+
class DuplicateAdjacentExceptionHandler(ProjectRule):
|
|
51
|
+
id = "duplicate-adjacent-exception-handler"
|
|
52
|
+
code = "SARJ488"
|
|
53
|
+
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
54
|
+
default_level=Severity.WARNING,
|
|
55
|
+
summary="Adjacent exception handlers repeat the same executable translation.",
|
|
56
|
+
rationale="Repeating one translation for adjacent statically known exception classes creates extra branches to maintain without different behavior.",
|
|
57
|
+
remediation="Consider one tuple handler for the adjacent exception types. Preserve distinct messages, cleanup, ordering, and intentional independently maintained translations.",
|
|
58
|
+
category=RuleCategory.MAINTAINABILITY,
|
|
59
|
+
autofix=AutofixPolicy.NONE,
|
|
60
|
+
limitations=(
|
|
61
|
+
"Only ordinary try statements with adjacent handlers, identical exception bindings, executable ASTs and comments are compared; exception groups and bare re-raise/pass handlers are excluded.",
|
|
62
|
+
"Handler types must be simple names or tuples of unshadowed builtin or statically resolved first-party Exception subclasses, with unconditional stable declarations/imports.",
|
|
63
|
+
"Dynamic namespace access, unknown external exception classes, generated/vendor sources, different comments, and intervening handlers are excluded. No autofix preserves intentional translation seams.",
|
|
64
|
+
),
|
|
65
|
+
examples=tuple(
|
|
66
|
+
RuleExample(
|
|
67
|
+
example_id=name,
|
|
68
|
+
title=title,
|
|
69
|
+
outcome=outcome,
|
|
70
|
+
files=(ExampleFile.python("app/service.py", source),),
|
|
71
|
+
focus_path=PurePosixPath("app/service.py"),
|
|
72
|
+
expected_count=count,
|
|
73
|
+
public=True,
|
|
74
|
+
)
|
|
75
|
+
for name, title, outcome, source, count in (
|
|
76
|
+
("repeated-translation", "Adjacent handlers translate identically", ExampleOutcome.MATCH, _BAD, 1),
|
|
77
|
+
("one-tuple-handler", "One handler owns the shared translation", ExampleOutcome.NO_MATCH, _GOOD, 0),
|
|
78
|
+
)
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
description = documentation.summary
|
|
82
|
+
|
|
83
|
+
@override
|
|
84
|
+
def check_context(self, context: PythonFileContext) -> list[Diagnostic]:
|
|
85
|
+
tree = context.tree
|
|
86
|
+
if context.generated or tree is None or "except" not in context.symbol_source or dynamic_namespace(context):
|
|
87
|
+
return []
|
|
88
|
+
project = context.session.project or ProjectIndexSet.single(context.path, context.source)
|
|
89
|
+
unit = project.unit_or_source(context.path, context.source, tree)
|
|
90
|
+
provenance = ResourceProvenance(context)
|
|
91
|
+
comments = scan_comments(context.source)
|
|
92
|
+
findings: list[Diagnostic] = []
|
|
93
|
+
for statement in context.nodes(ast.Try):
|
|
94
|
+
previous: ast.ExceptHandler | None = None
|
|
95
|
+
reported = False
|
|
96
|
+
for handler in statement.handlers:
|
|
97
|
+
safe = _safe_types(handler.type, context, provenance, project, unit, seen=set())
|
|
98
|
+
if previous is not None and safe and _same_translation(previous, handler, comments):
|
|
99
|
+
if not reported and not is_suppressed(context.source_lines, handler.lineno, self.code):
|
|
100
|
+
findings.append(
|
|
101
|
+
Diagnostic(
|
|
102
|
+
path=context.path,
|
|
103
|
+
line=handler.lineno,
|
|
104
|
+
col=handler.col_offset + 1,
|
|
105
|
+
code=self.code,
|
|
106
|
+
severity=Severity.WARNING,
|
|
107
|
+
message="Adjacent exception handlers have identical translations; consider one tuple handler, preserving intentional separate error contracts.",
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
reported = True
|
|
111
|
+
else:
|
|
112
|
+
reported = False
|
|
113
|
+
previous = handler if safe else None
|
|
114
|
+
return sorted(findings, key=lambda finding: (finding.line, finding.col))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _same_translation(first: ast.ExceptHandler, second: ast.ExceptHandler, comments: list[Comment]) -> bool:
|
|
118
|
+
if first.name != second.name or not first.body or first.type is None or second.type is None:
|
|
119
|
+
return False
|
|
120
|
+
if not {node.id for node in ast.walk(first.type) if isinstance(node, ast.Name)}.isdisjoint(
|
|
121
|
+
node.id for node in ast.walk(second.type) if isinstance(node, ast.Name)
|
|
122
|
+
):
|
|
123
|
+
return False
|
|
124
|
+
if len(first.body) == 1 and (
|
|
125
|
+
isinstance(first.body[0], ast.Pass) or (isinstance(first.body[0], ast.Raise) and first.body[0].exc is None)
|
|
126
|
+
):
|
|
127
|
+
return False
|
|
128
|
+
if ast.dump(ast.Module(body=first.body, type_ignores=[])) != ast.dump(
|
|
129
|
+
ast.Module(body=second.body, type_ignores=[])
|
|
130
|
+
):
|
|
131
|
+
return False
|
|
132
|
+
if any((first.end_lineno or first.lineno) < comment.line < second.lineno for comment in comments):
|
|
133
|
+
return False
|
|
134
|
+
return _comments(first, comments) == _comments(second, comments)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _comments(handler: ast.ExceptHandler, comments: list[Comment]) -> list[str]:
|
|
138
|
+
return [
|
|
139
|
+
comment.body for comment in comments if handler.lineno <= comment.line <= (handler.end_lineno or handler.lineno)
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _safe_types(
|
|
144
|
+
expression: ast.expr | None,
|
|
145
|
+
context: PythonFileContext,
|
|
146
|
+
provenance: ResourceProvenance,
|
|
147
|
+
project: ProjectIndexSet,
|
|
148
|
+
unit: SourceUnit | None,
|
|
149
|
+
*,
|
|
150
|
+
seen: set[SymbolRef],
|
|
151
|
+
) -> bool:
|
|
152
|
+
if isinstance(expression, ast.Tuple):
|
|
153
|
+
return bool(expression.elts) and all(
|
|
154
|
+
_safe_types(element, context, provenance, project, unit, seen=seen) for element in expression.elts
|
|
155
|
+
)
|
|
156
|
+
if not isinstance(expression, ast.Name):
|
|
157
|
+
return False
|
|
158
|
+
if (
|
|
159
|
+
expression.id in _BUILTIN_EXCEPTIONS
|
|
160
|
+
and context.imports.builtin_is_unshadowed(expression.id)
|
|
161
|
+
and provenance.binding(expression.id, expression) is None
|
|
162
|
+
):
|
|
163
|
+
return True
|
|
164
|
+
return _first_party_exception(expression, context, provenance, project, unit, seen=seen)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _first_party_exception(
|
|
168
|
+
expression: ast.Name,
|
|
169
|
+
context: PythonFileContext,
|
|
170
|
+
provenance: ResourceProvenance,
|
|
171
|
+
project: ProjectIndexSet,
|
|
172
|
+
unit: SourceUnit | None,
|
|
173
|
+
*,
|
|
174
|
+
seen: set[SymbolRef],
|
|
175
|
+
) -> bool:
|
|
176
|
+
from sarj_python_lint._file_context import PythonFileContext # ruff: ignore[import-outside-top-level] -- defer until the rule registry finishes loading.
|
|
177
|
+
|
|
178
|
+
binding = provenance.binding(expression.id, expression)
|
|
179
|
+
if not isinstance(binding, ast.ClassDef | ast.alias) or unit is None:
|
|
180
|
+
return False
|
|
181
|
+
if isinstance(binding, ast.alias):
|
|
182
|
+
statement = context.parents.get(binding)
|
|
183
|
+
if statement is None or context.parents.get(statement) is not context.tree:
|
|
184
|
+
return False
|
|
185
|
+
summary = project.class_for(unit, expression)
|
|
186
|
+
if (
|
|
187
|
+
summary is None
|
|
188
|
+
or summary.symbol in seen
|
|
189
|
+
or (owner := project.source_unit(summary.symbol.module)) is None
|
|
190
|
+
or owner.tree is None
|
|
191
|
+
or is_generated(owner.path, owner.source)
|
|
192
|
+
):
|
|
193
|
+
return False
|
|
194
|
+
definition_context = (
|
|
195
|
+
context if owner.path == context.path else PythonFileContext(owner.path, owner.source, context.session)
|
|
196
|
+
)
|
|
197
|
+
definition_provenance = provenance if definition_context is context else ResourceProvenance(definition_context)
|
|
198
|
+
declaration = next(
|
|
199
|
+
(node for node in definition_context.nodes(ast.ClassDef) if node.name == summary.symbol.name), None
|
|
200
|
+
)
|
|
201
|
+
return (
|
|
202
|
+
declaration is not None
|
|
203
|
+
and stable_class(definition_context, declaration, definition_provenance)
|
|
204
|
+
and len(declaration.bases) == 1
|
|
205
|
+
and _safe_types(
|
|
206
|
+
declaration.bases[0],
|
|
207
|
+
definition_context,
|
|
208
|
+
definition_provenance,
|
|
209
|
+
project,
|
|
210
|
+
owner,
|
|
211
|
+
seen=seen | {summary.symbol},
|
|
212
|
+
)
|
|
213
|
+
)
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import PurePosixPath
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar, final, override
|
|
6
|
+
|
|
7
|
+
from sarj_python_lint.rule_base import (
|
|
8
|
+
AutofixPolicy,
|
|
9
|
+
Diagnostic,
|
|
10
|
+
ExampleFile,
|
|
11
|
+
ExampleOutcome,
|
|
12
|
+
Rule,
|
|
13
|
+
RuleCategory,
|
|
14
|
+
RuleDocumentation,
|
|
15
|
+
RuleExample,
|
|
16
|
+
Severity,
|
|
17
|
+
is_suppressed,
|
|
18
|
+
)
|
|
19
|
+
from sarj_python_lint.rules._closed_helpers import call_matches, closed_references, direct_call, executable_body
|
|
20
|
+
from sarj_python_lint.rules._resource_provenance import ResourceProvenance
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from sarj_python_lint._file_context import PythonFileContext
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_HELPER = "def _space(match: re.Match[str]) -> str:\n full = match.group(0)\n digits = match.group(1)\n spaced = ' '.join(digits)\n return full.replace(digits, spaced)\n"
|
|
28
|
+
_BAD = (
|
|
29
|
+
"import re\nFIRST = re.compile(r'item (\\d+)')\nSECOND = re.compile(r'code (\\d+)')\n"
|
|
30
|
+
+ _HELPER
|
|
31
|
+
+ _HELPER.replace("_space", "_spread")
|
|
32
|
+
+ "FIRST.sub(_space, text)\nSECOND.sub(_spread, text)\n"
|
|
33
|
+
)
|
|
34
|
+
_GOOD = (
|
|
35
|
+
"import re\nFIRST = re.compile(r'item (\\d+)')\nSECOND = re.compile(r'code (\\d+)')\n"
|
|
36
|
+
+ _HELPER
|
|
37
|
+
+ "FIRST.sub(_space, text)\nSECOND.sub(_space, text)\n"
|
|
38
|
+
)
|
|
39
|
+
_PATTERN_METHODS = frozenset({"sub", "subn", "match", "fullmatch", "search", "findall", "finditer", "split"})
|
|
40
|
+
_SUB_REQUIRED_ARGUMENTS = 2
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@final
|
|
44
|
+
class DuplicateClosedPureHelper(Rule):
|
|
45
|
+
id = "duplicate-closed-pure-helper"
|
|
46
|
+
code = "SARJ489"
|
|
47
|
+
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
48
|
+
default_level=Severity.WARNING,
|
|
49
|
+
summary="Closed private helpers repeat one proven native regex/string transformation.",
|
|
50
|
+
rationale="Identical private transformations duplicate maintained behavior even when different caller patterns choose the domains in which they run.",
|
|
51
|
+
remediation="Reuse one existing transformation while keeping each caller's pattern, order and examples. Preserve documented independent versioning or patch contracts explicitly.",
|
|
52
|
+
category=RuleCategory.MAINTAINABILITY,
|
|
53
|
+
autofix=AutofixPolicy.NONE,
|
|
54
|
+
limitations=(
|
|
55
|
+
"Only undecorated private module-level functions in one authored source file with identical signatures and executable ASTs are compared; no cross-file extraction is suggested.",
|
|
56
|
+
"The supported pure grammar is one re.Match[str] parameter, straight-line local assignments, immutable literals, native Match.group, str.join and str.replace, and a str result. Defaults and unknown calls are excluded.",
|
|
57
|
+
"Every reference must be a known direct call or the first replacement argument to Pattern.sub on a stable, unescaped native re.compile binding with a string pattern.",
|
|
58
|
+
"Escapes, aliases, reflection, rebinding, exports, patch strings, decorators and generated/vendor paths are excluded. Distinct domain docstrings alone do not create runtime behavior; retain useful examples at the surviving owner. No autofix.",
|
|
59
|
+
),
|
|
60
|
+
examples=tuple(
|
|
61
|
+
RuleExample(
|
|
62
|
+
example_id=name,
|
|
63
|
+
title=title,
|
|
64
|
+
outcome=outcome,
|
|
65
|
+
files=(ExampleFile.python("app/formatters.py", source),),
|
|
66
|
+
focus_path=PurePosixPath("app/formatters.py"),
|
|
67
|
+
expected_count=count,
|
|
68
|
+
public=True,
|
|
69
|
+
)
|
|
70
|
+
for name, title, outcome, source, count in (
|
|
71
|
+
(
|
|
72
|
+
"duplicate-native-transformation",
|
|
73
|
+
"Different patterns use identical closed callbacks",
|
|
74
|
+
ExampleOutcome.MATCH,
|
|
75
|
+
_BAD,
|
|
76
|
+
1,
|
|
77
|
+
),
|
|
78
|
+
(
|
|
79
|
+
"shared-existing-transformation",
|
|
80
|
+
"Patterns retain their choices and share one transformation",
|
|
81
|
+
ExampleOutcome.NO_MATCH,
|
|
82
|
+
_GOOD,
|
|
83
|
+
0,
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
description = documentation.summary
|
|
89
|
+
|
|
90
|
+
@override
|
|
91
|
+
def check_context(self, context: PythonFileContext) -> list[Diagnostic]:
|
|
92
|
+
tree = context.tree
|
|
93
|
+
if context.generated or tree is None or ".group" not in context.symbol_source:
|
|
94
|
+
return []
|
|
95
|
+
provenance = ResourceProvenance(context)
|
|
96
|
+
owners: dict[str, ast.FunctionDef] = {}
|
|
97
|
+
findings: list[Diagnostic] = []
|
|
98
|
+
for function in tree.body:
|
|
99
|
+
if (
|
|
100
|
+
not isinstance(function, ast.FunctionDef)
|
|
101
|
+
or function.returns is None
|
|
102
|
+
or not _pure_body(function, context)
|
|
103
|
+
):
|
|
104
|
+
continue
|
|
105
|
+
references = closed_references(context, function, provenance)
|
|
106
|
+
if references is None or any(
|
|
107
|
+
not _known_reference(reference, function, context, provenance) for reference in references
|
|
108
|
+
):
|
|
109
|
+
continue
|
|
110
|
+
key = (
|
|
111
|
+
ast.dump(function.args)
|
|
112
|
+
+ ast.dump(function.returns)
|
|
113
|
+
+ ast.dump(ast.Module(body=executable_body(function), type_ignores=[]))
|
|
114
|
+
)
|
|
115
|
+
if key not in owners:
|
|
116
|
+
owners[key] = function
|
|
117
|
+
elif not is_suppressed(context.source_lines, function.lineno, self.code):
|
|
118
|
+
findings.append(
|
|
119
|
+
Diagnostic(
|
|
120
|
+
path=context.path,
|
|
121
|
+
line=function.lineno,
|
|
122
|
+
col=function.col_offset + 1,
|
|
123
|
+
code=self.code,
|
|
124
|
+
severity=Severity.WARNING,
|
|
125
|
+
message=f"`{function.name}` repeats the closed native transformation in `{owners[key].name}`; reuse that helper while preserving caller patterns, useful examples, and intentional independent contracts.",
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
return findings
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _pure_body(function: ast.FunctionDef, context: PythonFileContext) -> bool:
|
|
132
|
+
arguments = [*function.args.posonlyargs, *function.args.args, *function.args.kwonlyargs]
|
|
133
|
+
if (
|
|
134
|
+
len(arguments) != 1
|
|
135
|
+
or function.args.defaults
|
|
136
|
+
or any(function.args.kw_defaults)
|
|
137
|
+
or not isinstance(function.returns, ast.Name)
|
|
138
|
+
):
|
|
139
|
+
return False
|
|
140
|
+
if function.returns.id != "str" or not context.imports.builtin_is_unshadowed("str"):
|
|
141
|
+
return False
|
|
142
|
+
annotation = arguments[0].annotation
|
|
143
|
+
if (
|
|
144
|
+
not isinstance(annotation, ast.Subscript)
|
|
145
|
+
or context.imports.resolved_qualified_name(annotation.value) != "re.Match"
|
|
146
|
+
or not isinstance(annotation.slice, ast.Name)
|
|
147
|
+
or annotation.slice.id != "str"
|
|
148
|
+
):
|
|
149
|
+
return False
|
|
150
|
+
types = {arguments[0].arg: "match"}
|
|
151
|
+
body = executable_body(function)
|
|
152
|
+
if not body or not isinstance(body[-1], ast.Return):
|
|
153
|
+
return False
|
|
154
|
+
for statement in body[:-1]:
|
|
155
|
+
if (
|
|
156
|
+
not isinstance(statement, ast.Assign)
|
|
157
|
+
or len(statement.targets) != 1
|
|
158
|
+
or not isinstance(statement.targets[0], ast.Name)
|
|
159
|
+
):
|
|
160
|
+
return False
|
|
161
|
+
name = statement.targets[0].id
|
|
162
|
+
kind = _native_type(statement.value, types)
|
|
163
|
+
if name in types or kind is None:
|
|
164
|
+
return False
|
|
165
|
+
types[name] = kind
|
|
166
|
+
return body[-1].value is not None and _native_type(body[-1].value, types) == "str"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _native_type(expression: ast.expr, types: dict[str, str]) -> str | None:
|
|
170
|
+
if isinstance(expression, ast.Name):
|
|
171
|
+
return types.get(expression.id)
|
|
172
|
+
if isinstance(expression, ast.Constant):
|
|
173
|
+
return "str" if isinstance(expression.value, str) else "int" if type(expression.value) is int else None
|
|
174
|
+
if not isinstance(expression, ast.Call) or expression.keywords or not isinstance(expression.func, ast.Attribute):
|
|
175
|
+
return None
|
|
176
|
+
receiver = _native_type(expression.func.value, types)
|
|
177
|
+
arguments = [_native_type(argument, types) for argument in expression.args]
|
|
178
|
+
if receiver == "match" and expression.func.attr == "group" and arguments == ["int"]:
|
|
179
|
+
return "str"
|
|
180
|
+
if receiver == "str" and (
|
|
181
|
+
(expression.func.attr == "join" and arguments == ["str"])
|
|
182
|
+
or (expression.func.attr == "replace" and arguments in (["str", "str"], ["str", "str", "int"]))
|
|
183
|
+
):
|
|
184
|
+
return "str"
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _known_reference(
|
|
189
|
+
reference: ast.Name, function: ast.FunctionDef, context: PythonFileContext, provenance: ResourceProvenance
|
|
190
|
+
) -> bool:
|
|
191
|
+
call = direct_call(reference, context)
|
|
192
|
+
if call is not None:
|
|
193
|
+
return call_matches(function, call)
|
|
194
|
+
parent = context.parents.get(reference)
|
|
195
|
+
if (
|
|
196
|
+
not isinstance(parent, ast.Call)
|
|
197
|
+
or len(parent.args) < _SUB_REQUIRED_ARGUMENTS
|
|
198
|
+
or parent.args[0] is not reference
|
|
199
|
+
or not isinstance(parent.func, ast.Attribute)
|
|
200
|
+
or parent.func.attr != "sub"
|
|
201
|
+
):
|
|
202
|
+
return False
|
|
203
|
+
return (
|
|
204
|
+
isinstance(parent.func.value, ast.Name)
|
|
205
|
+
and _native_pattern(parent.func.value, context, provenance)
|
|
206
|
+
and not any(isinstance(argument, ast.Starred) for argument in parent.args)
|
|
207
|
+
and not any(keyword.arg is None for keyword in parent.keywords)
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _native_pattern(receiver: ast.Name, context: PythonFileContext, provenance: ResourceProvenance) -> bool:
|
|
212
|
+
constructor = provenance.constructor(receiver, receiver)
|
|
213
|
+
binding = provenance.binding(receiver.id, receiver)
|
|
214
|
+
if (
|
|
215
|
+
constructor is None
|
|
216
|
+
or binding is None
|
|
217
|
+
or not provenance.imported(constructor.func, "re.compile")
|
|
218
|
+
or not constructor.args
|
|
219
|
+
):
|
|
220
|
+
return False
|
|
221
|
+
if not _string_pattern(constructor.args[0], provenance, frozenset()) or any(
|
|
222
|
+
isinstance(node.value, str)
|
|
223
|
+
and (receiver.id in node.value.split(".") or node.value in {"re.compile", "re.Match", "re.Pattern"})
|
|
224
|
+
for node in context.nodes(ast.Constant)
|
|
225
|
+
):
|
|
226
|
+
return False
|
|
227
|
+
declaration = context.parents.get(binding)
|
|
228
|
+
if declaration is None or context.parents.get(declaration) is not context.tree or receiver.id in provenance.mutated:
|
|
229
|
+
return False
|
|
230
|
+
for read in provenance.reads.get(receiver.id, ()):
|
|
231
|
+
attribute = context.parents.get(read)
|
|
232
|
+
use = context.parents.get(attribute) if attribute is not None else None
|
|
233
|
+
if (
|
|
234
|
+
provenance.binding(read.id, read) is not binding
|
|
235
|
+
or not isinstance(attribute, ast.Attribute)
|
|
236
|
+
or attribute.attr not in _PATTERN_METHODS
|
|
237
|
+
or not isinstance(use, ast.Call)
|
|
238
|
+
or use.func is not attribute
|
|
239
|
+
):
|
|
240
|
+
return False
|
|
241
|
+
return True
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _string_pattern(expression: ast.expr, provenance: ResourceProvenance, seen: frozenset[str]) -> bool:
|
|
245
|
+
if isinstance(expression, ast.Constant):
|
|
246
|
+
return isinstance(expression.value, str)
|
|
247
|
+
if isinstance(expression, ast.BinOp) and isinstance(expression.op, ast.Add):
|
|
248
|
+
return _string_pattern(expression.left, provenance, seen) and _string_pattern(
|
|
249
|
+
expression.right, provenance, seen
|
|
250
|
+
)
|
|
251
|
+
if not isinstance(expression, ast.Name) or expression.id in seen:
|
|
252
|
+
return False
|
|
253
|
+
binding = provenance.binding(expression.id, expression)
|
|
254
|
+
if not isinstance(binding, ast.Name):
|
|
255
|
+
return False
|
|
256
|
+
statement = provenance.context.parents.get(binding)
|
|
257
|
+
if statement is None or provenance.context.parents.get(statement) is not provenance.context.tree:
|
|
258
|
+
return False
|
|
259
|
+
value = provenance.assignment_value(binding, expression)
|
|
260
|
+
return value is not None and _string_pattern(value, provenance, seen | {expression.id})
|