sarj-python-lint 1.2.4__tar.gz → 1.2.7__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.2.4 → sarj_python_lint-1.2.7}/PKG-INFO +1 -1
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/pyproject.toml +1 -1
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/_registry.py +2 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/_test_composition.py +15 -4
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +170 -28
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/mock_without_spec.py +1 -1
- sarj_python_lint-1.2.7/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +227 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/repeated_test_composition.py +1 -1
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +248 -19
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/.gitignore +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/LICENSE +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/README.md +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/_analysis_session.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/_file_context.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/_python_target.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/benchmark.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/json_boundary.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/_ast_position.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/_fastapi.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/_imports.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/_nominal_project.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/_project_index.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
- {sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/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.2.
|
|
3
|
+
Version: 1.2.7
|
|
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/
|
|
@@ -75,6 +75,7 @@ from sarj_python_lint.rules.no_secret_in_log import NoSecretInLog
|
|
|
75
75
|
from sarj_python_lint.rules.no_select_star import NoSelectStar
|
|
76
76
|
from sarj_python_lint.rules.no_service_behavior_in_settings import NoServiceBehaviorInSettings
|
|
77
77
|
from sarj_python_lint.rules.no_string_concat_in_loop import NoStringConcatInLoop
|
|
78
|
+
from sarj_python_lint.rules.no_swallowed_asyncio_cancellation import NoSwallowedAsyncioCancellation
|
|
78
79
|
from sarj_python_lint.rules.no_tautological_expect import NoTautologicalExpect
|
|
79
80
|
from sarj_python_lint.rules.no_typed_doc_sections import NoTypedDocSections
|
|
80
81
|
from sarj_python_lint.rules.no_unique_violation_message_match import (
|
|
@@ -181,6 +182,7 @@ if TYPE_CHECKING:
|
|
|
181
182
|
|
|
182
183
|
REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
183
184
|
{
|
|
185
|
+
NoSwallowedAsyncioCancellation.id: NoSwallowedAsyncioCancellation,
|
|
184
186
|
AsyncMockCallWithoutAwaitAssertion.id: AsyncMockCallWithoutAwaitAssertion,
|
|
185
187
|
ComplexPostgresQueryRequiresArchitectureReview.id: ComplexPostgresQueryRequiresArchitectureReview,
|
|
186
188
|
ExcessiveCommentary.id: ExcessiveCommentary,
|
{sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/_test_composition.py
RENAMED
|
@@ -189,16 +189,27 @@ def local_names(function: Function) -> set[str]:
|
|
|
189
189
|
return names
|
|
190
190
|
|
|
191
191
|
|
|
192
|
-
def changed_before(function: Function, call: ast.Call) -> set[str]:
|
|
192
|
+
def changed_before(function: Function, call: ast.Call, aliases: dict[str, ast.expr]) -> set[str]:
|
|
193
193
|
changed: set[str] = set()
|
|
194
194
|
for node in direct_nodes(function):
|
|
195
|
-
if not isinstance(node, (ast.Call, ast.Attribute)) or node.lineno >=
|
|
195
|
+
if not isinstance(node, (ast.Call, ast.Attribute, ast.Subscript)) or (node.lineno, node.col_offset) >= (
|
|
196
|
+
call.lineno,
|
|
197
|
+
call.col_offset,
|
|
198
|
+
):
|
|
196
199
|
continue
|
|
197
200
|
target = node.func.value if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) else None
|
|
198
|
-
if isinstance(node, ast.Attribute) and isinstance(node.ctx, (ast.Store, ast.Del)):
|
|
201
|
+
if isinstance(node, (ast.Attribute, ast.Subscript)) and isinstance(node.ctx, (ast.Store, ast.Del)):
|
|
199
202
|
target = node.value
|
|
200
|
-
|
|
203
|
+
while isinstance(target, (ast.Attribute, ast.Subscript)):
|
|
204
|
+
target = target.value
|
|
205
|
+
seen: set[str] = set()
|
|
206
|
+
while isinstance(target, ast.Name) and target.id not in seen:
|
|
207
|
+
seen.add(target.id)
|
|
201
208
|
changed.add(target.id)
|
|
209
|
+
value = aliases.get(target.id)
|
|
210
|
+
if value is None or (value.lineno, value.col_offset) >= (node.lineno, node.col_offset):
|
|
211
|
+
break
|
|
212
|
+
target = value
|
|
202
213
|
return changed
|
|
203
214
|
|
|
204
215
|
|
|
@@ -41,6 +41,7 @@ _AWAIT_ASSERTIONS: Final = frozenset(
|
|
|
41
41
|
}
|
|
42
42
|
)
|
|
43
43
|
_AWAIT_STATE: Final = frozenset({"await_count", "await_args", "await_args_list"})
|
|
44
|
+
_CALL_STATE: Final = frozenset({"called", "call_count"})
|
|
44
45
|
_CONSTRUCTOR_KEYWORDS: Final = frozenset({"return_value", "side_effect", "name", "wraps", "unsafe", "spec", "spec_set"})
|
|
45
46
|
_MUTATORS: Final = frozenset({"reset_mock", "configure_mock", "attach_mock", "mock_add_spec"})
|
|
46
47
|
_IMMEDIATE_CHILD_PARTS: Final = 2
|
|
@@ -86,10 +87,25 @@ class AsyncMockCallWithoutAwaitAssertion(Rule):
|
|
|
86
87
|
"Only direct statements in collected test functions and methods are inspected; fixtures, helpers, nested blocks, and generated files are excluded.",
|
|
87
88
|
"Requires a fresh local unittest.mock.AsyncMock constructor with unshadowed imports; aliases, reassignment, reset, and ambiguous configuration are excluded.",
|
|
88
89
|
"Only the mock itself and unchanged immediate children of unspecced, unwrapped mocks are inferred; spec children, return values, and deeper chains are excluded.",
|
|
89
|
-
"
|
|
90
|
+
"Positive call-count and called-state assertions are covered as well as call-assertion methods. Await-state assertions must prove at least one await; argument equality and exact call/await counts are not compared.",
|
|
91
|
+
"Boolean conjunctions combine evidence; disjunctions require evidence in every branch. Dynamic counts and call lists are not inferred.",
|
|
90
92
|
"Custom assertion helpers and intentional scheduling contracts may require a reasoned local suppression.",
|
|
91
93
|
),
|
|
92
94
|
examples=(
|
|
95
|
+
RuleExample(
|
|
96
|
+
example_id="numeric-call-with-vacuous-await-check",
|
|
97
|
+
title="A nonnegative await count does not prove execution",
|
|
98
|
+
outcome=ExampleOutcome.MATCH,
|
|
99
|
+
files=(
|
|
100
|
+
ExampleFile.python(
|
|
101
|
+
"tests/test_delivery.py",
|
|
102
|
+
"from unittest.mock import AsyncMock\n\nasync def test_delivery():\n send = AsyncMock()\n await deliver(send)\n assert send.call_count == 2\n assert send.await_count >= 0\n",
|
|
103
|
+
),
|
|
104
|
+
),
|
|
105
|
+
focus_path=PurePosixPath("tests/test_delivery.py"),
|
|
106
|
+
expected_count=1,
|
|
107
|
+
public=False,
|
|
108
|
+
),
|
|
93
109
|
RuleExample(
|
|
94
110
|
example_id="call-only-async-mock",
|
|
95
111
|
title="A call assertion does not prove awaiting",
|
|
@@ -129,7 +145,7 @@ class AsyncMockCallWithoutAwaitAssertion(Rule):
|
|
|
129
145
|
if (
|
|
130
146
|
path.suffix != ".py"
|
|
131
147
|
or "AsyncMock" not in source
|
|
132
|
-
or "assert_called"
|
|
148
|
+
or not any(token in source for token in ("assert_called", "call_count", ".called"))
|
|
133
149
|
or not (path.stem.startswith("test_") or path.stem.endswith("_test"))
|
|
134
150
|
or context.generated
|
|
135
151
|
):
|
|
@@ -186,30 +202,46 @@ def _test_imports(module: ImportIndex, test: _TestFunction) -> ImportIndex:
|
|
|
186
202
|
return ImportIndex(MappingProxyType(bindings), module.shadowed_names | local.shadowed_names)
|
|
187
203
|
|
|
188
204
|
|
|
189
|
-
def _uncovered_calls(
|
|
205
|
+
def _uncovered_calls(
|
|
206
|
+
imports: ImportIndex, test: _TestFunction, writes: set[_Reference]
|
|
207
|
+
) -> Iterator[ast.Call | ast.Attribute]:
|
|
190
208
|
nodes = tuple(walk_ast(test))
|
|
191
209
|
bindings = _mock_bindings(imports, test.body, writes)
|
|
192
210
|
covered = _await_oracles(test.body)
|
|
193
211
|
reported: set[_Reference] = set()
|
|
194
212
|
for statement in test.body:
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
213
|
+
for reference, oracle in _call_oracles(statement):
|
|
214
|
+
if not reference or reference in covered or reference in reported:
|
|
215
|
+
continue
|
|
216
|
+
binding = bindings.get(reference[0])
|
|
217
|
+
if (
|
|
218
|
+
binding is None
|
|
219
|
+
or not _is_async_reference(reference, binding[1])
|
|
220
|
+
or any(_invalidates_reference(node, reference, binding[0]) for node in nodes)
|
|
198
221
|
):
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
222
|
+
continue
|
|
223
|
+
reported.add(reference)
|
|
224
|
+
yield oracle
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _call_oracles(statement: ast.stmt) -> Iterator[tuple[_Reference, ast.Call | ast.Attribute]]:
|
|
228
|
+
match statement:
|
|
229
|
+
case ast.Expr(value=ast.Call(func=ast.Attribute(value=receiver, attr=method)) as call) if (
|
|
230
|
+
method in _CALL_ASSERTIONS
|
|
231
|
+
):
|
|
232
|
+
yield _reference(receiver), call
|
|
233
|
+
case ast.Assert(test=condition):
|
|
234
|
+
for reference in sorted(_positive_states(condition, _CALL_STATE)):
|
|
235
|
+
oracle = next(
|
|
236
|
+
node
|
|
237
|
+
for node in walk_ast(condition)
|
|
238
|
+
if isinstance(node, ast.Attribute)
|
|
239
|
+
and node.attr in _CALL_STATE
|
|
240
|
+
and _reference(node.value) == reference
|
|
241
|
+
)
|
|
242
|
+
yield reference, oracle
|
|
243
|
+
case _:
|
|
244
|
+
pass
|
|
213
245
|
|
|
214
246
|
|
|
215
247
|
def _mock_bindings(
|
|
@@ -269,21 +301,127 @@ def _await_oracles(statements: list[ast.stmt]) -> set[_Reference]:
|
|
|
269
301
|
covered: set[_Reference] = set()
|
|
270
302
|
for statement in statements:
|
|
271
303
|
match statement:
|
|
272
|
-
case ast.Expr(value=ast.Call(func=ast.Attribute(value=receiver, attr=method))) if (
|
|
304
|
+
case ast.Expr(value=ast.Call(func=ast.Attribute(value=receiver, attr=method)) as call) if (
|
|
273
305
|
method in _AWAIT_ASSERTIONS
|
|
274
306
|
):
|
|
275
|
-
|
|
307
|
+
if method != "assert_has_awaits" or _has_nonempty_calls_argument(call):
|
|
308
|
+
covered.add(_reference(receiver))
|
|
276
309
|
case ast.Assert(test=condition):
|
|
277
|
-
covered.update(
|
|
278
|
-
_reference(node.value)
|
|
279
|
-
for node in walk_ast(condition)
|
|
280
|
-
if isinstance(node, ast.Attribute) and node.attr in _AWAIT_STATE
|
|
281
|
-
)
|
|
310
|
+
covered.update(_positive_states(condition, _AWAIT_STATE))
|
|
282
311
|
case _:
|
|
283
312
|
pass
|
|
284
313
|
return covered
|
|
285
314
|
|
|
286
315
|
|
|
316
|
+
def _has_nonempty_calls_argument(call: ast.Call) -> bool:
|
|
317
|
+
if call.args:
|
|
318
|
+
return _nonempty_sequence(call.args[0])
|
|
319
|
+
return any(item.arg == "calls" and _nonempty_sequence(item.value) for item in call.keywords)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _positive_states(condition: ast.expr, states: frozenset[str]) -> set[_Reference]:
|
|
323
|
+
if isinstance(condition, ast.UnaryOp) and isinstance(condition.op, ast.Not):
|
|
324
|
+
operand = condition.operand
|
|
325
|
+
if isinstance(operand, ast.Compare) and len(operand.ops) == 1:
|
|
326
|
+
inverse = _negated_operator(operand.ops[0])
|
|
327
|
+
if inverse is not None:
|
|
328
|
+
return _comparison_states(operand.left, inverse, operand.comparators[0], states) | _comparison_states(
|
|
329
|
+
operand.comparators[0], _reversed_operator(inverse), operand.left, states
|
|
330
|
+
)
|
|
331
|
+
return set()
|
|
332
|
+
if isinstance(condition, ast.BoolOp):
|
|
333
|
+
branches = [_positive_states(value, states) for value in condition.values]
|
|
334
|
+
return (
|
|
335
|
+
branches[0].union(*branches[1:])
|
|
336
|
+
if isinstance(condition.op, ast.And)
|
|
337
|
+
else branches[0].intersection(*branches[1:])
|
|
338
|
+
)
|
|
339
|
+
if isinstance(condition, ast.Attribute) and condition.attr in states:
|
|
340
|
+
return {_reference(condition.value)}
|
|
341
|
+
if isinstance(condition, ast.Compare):
|
|
342
|
+
result: set[_Reference] = set()
|
|
343
|
+
for left, operator, right in zip(
|
|
344
|
+
(condition.left, *condition.comparators[:-1]), condition.ops, condition.comparators, strict=True
|
|
345
|
+
):
|
|
346
|
+
result.update(_comparison_states(left, operator, right, states))
|
|
347
|
+
result.update(_comparison_states(right, _reversed_operator(operator), left, states))
|
|
348
|
+
return result
|
|
349
|
+
return set()
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _negated_operator(operator: ast.cmpop) -> ast.cmpop | None:
|
|
353
|
+
inverse: dict[type[ast.cmpop], type[ast.cmpop]] = {
|
|
354
|
+
ast.Eq: ast.NotEq,
|
|
355
|
+
ast.NotEq: ast.Eq,
|
|
356
|
+
ast.Is: ast.IsNot,
|
|
357
|
+
ast.IsNot: ast.Is,
|
|
358
|
+
ast.Lt: ast.GtE,
|
|
359
|
+
ast.LtE: ast.Gt,
|
|
360
|
+
ast.Gt: ast.LtE,
|
|
361
|
+
ast.GtE: ast.Lt,
|
|
362
|
+
}
|
|
363
|
+
factory = inverse.get(type(operator))
|
|
364
|
+
return factory() if factory is not None else None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _reversed_operator(operator: ast.cmpop) -> ast.cmpop:
|
|
368
|
+
match operator:
|
|
369
|
+
case ast.Lt():
|
|
370
|
+
return ast.Gt()
|
|
371
|
+
case ast.LtE():
|
|
372
|
+
return ast.GtE()
|
|
373
|
+
case ast.Gt():
|
|
374
|
+
return ast.Lt()
|
|
375
|
+
case ast.GtE():
|
|
376
|
+
return ast.LtE()
|
|
377
|
+
case _:
|
|
378
|
+
return operator
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _comparison_states(left: ast.expr, operator: ast.cmpop, right: ast.expr, states: frozenset[str]) -> set[_Reference]:
|
|
382
|
+
if not isinstance(left, ast.Attribute):
|
|
383
|
+
return set()
|
|
384
|
+
if (
|
|
385
|
+
states == _AWAIT_STATE
|
|
386
|
+
and isinstance(left.value, ast.Attribute)
|
|
387
|
+
and left.value.attr == "await_args"
|
|
388
|
+
and left.attr in {"args", "kwargs"}
|
|
389
|
+
):
|
|
390
|
+
return {_reference(left.value.value)}
|
|
391
|
+
if left.attr not in states:
|
|
392
|
+
return set()
|
|
393
|
+
positive = False
|
|
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
|
|
399
|
+
)
|
|
400
|
+
elif left.attr == "await_args" and isinstance(right, ast.Constant) and right.value is None:
|
|
401
|
+
positive = isinstance(operator, (ast.NotEq, ast.IsNot))
|
|
402
|
+
elif left.attr == "await_args_list":
|
|
403
|
+
positive = (isinstance(operator, ast.Eq) and _nonempty_sequence(right)) or (
|
|
404
|
+
isinstance(operator, ast.NotEq) and isinstance(right, ast.List) and not right.elts
|
|
405
|
+
)
|
|
406
|
+
return {_reference(left.value)} if positive else set()
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _proves_positive_count(operator: ast.cmpop, value: int) -> bool:
|
|
410
|
+
match operator:
|
|
411
|
+
case ast.Eq() | ast.GtE():
|
|
412
|
+
return value > 0
|
|
413
|
+
case ast.NotEq():
|
|
414
|
+
return value == 0
|
|
415
|
+
case ast.Gt():
|
|
416
|
+
return value >= 0
|
|
417
|
+
case _:
|
|
418
|
+
return False
|
|
419
|
+
|
|
420
|
+
|
|
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
|
+
|
|
287
425
|
def _invalidates_reference(node: ast.AST, reference: _Reference, binding: ast.Name) -> bool:
|
|
288
426
|
match node:
|
|
289
427
|
case ast.Name(id=name, ctx=ast.Store() | ast.Del()):
|
|
@@ -295,7 +433,11 @@ def _invalidates_reference(node: ast.AST, reference: _Reference, binding: ast.Na
|
|
|
295
433
|
case ast.Attribute(ctx=ast.Store() | ast.Del()):
|
|
296
434
|
changed = _reference(node)
|
|
297
435
|
return bool(changed) and (
|
|
298
|
-
reference[: len(changed)] == changed
|
|
436
|
+
reference[: len(changed)] == changed
|
|
437
|
+
or (
|
|
438
|
+
changed[:-1] == reference
|
|
439
|
+
and (changed[-1].startswith("assert") or changed[-1] in _CALL_STATE | _AWAIT_STATE)
|
|
440
|
+
)
|
|
299
441
|
)
|
|
300
442
|
case ast.Call(func=ast.Attribute(value=receiver, attr=method)) if method in _MUTATORS:
|
|
301
443
|
changed = _reference(receiver)
|
{sarj_python_lint-1.2.4 → sarj_python_lint-1.2.7}/src/sarj_python_lint/rules/mock_without_spec.py
RENAMED
|
@@ -789,7 +789,7 @@ def _keyword_supplies_contract(keyword: ast.keyword, names: _MockNames, symbol:
|
|
|
789
789
|
return False
|
|
790
790
|
return names.resolve(keyword.value) not in _UNSPECCED_FACTORIES
|
|
791
791
|
return keyword.arg in _PATCH_CONTRACT_KEYWORDS and not (
|
|
792
|
-
isinstance(keyword.value, ast.Constant) and keyword.value.value is None
|
|
792
|
+
isinstance(keyword.value, ast.Constant) and (keyword.value.value is None or keyword.value.value is False)
|
|
793
793
|
)
|
|
794
794
|
|
|
795
795
|
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from itertools import pairwise
|
|
5
|
+
from pathlib import PurePosixPath
|
|
6
|
+
from typing import TYPE_CHECKING, ClassVar, final, override
|
|
7
|
+
|
|
8
|
+
from sarj_python_lint.rule_base import (
|
|
9
|
+
AutofixPolicy,
|
|
10
|
+
Diagnostic,
|
|
11
|
+
ExampleFile,
|
|
12
|
+
ExampleOutcome,
|
|
13
|
+
Rule,
|
|
14
|
+
RuleCategory,
|
|
15
|
+
RuleDocumentation,
|
|
16
|
+
RuleExample,
|
|
17
|
+
Severity,
|
|
18
|
+
is_suppressed,
|
|
19
|
+
)
|
|
20
|
+
from sarj_python_lint.rules._ast_index import nodes
|
|
21
|
+
from sarj_python_lint.rules._paths import is_test_path, is_test_support_path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Iterator
|
|
26
|
+
|
|
27
|
+
from sarj_python_lint._file_context import PythonFileContext
|
|
28
|
+
from sarj_python_lint.rules._imports import ImportIndex
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_ASYNCIO = frozenset({"asyncio", "asyncio.exceptions"})
|
|
32
|
+
_SIMPLE_STATEMENTS = (ast.Expr, ast.Assign, ast.AnnAssign, ast.AugAssign, ast.Delete, ast.Pass)
|
|
33
|
+
_MESSAGE = (
|
|
34
|
+
"CancelledError handler completes normally; re-raise caller cancellation after cleanup, "
|
|
35
|
+
"or guard intentional child cancellation with the current task's cancellation state."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@final
|
|
40
|
+
class NoSwallowedAsyncioCancellation(Rule):
|
|
41
|
+
id = "no-swallowed-asyncio-cancellation"
|
|
42
|
+
code = "SARJ458"
|
|
43
|
+
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
44
|
+
default_level=Severity.WARNING,
|
|
45
|
+
summary="Preserve cancellation when an async operation is cancelled.",
|
|
46
|
+
rationale=(
|
|
47
|
+
"Returning or falling through after catching CancelledError can make cancelled work look successful "
|
|
48
|
+
"and interfere with task groups and timeout shutdown. Child cancellation must be distinguished from "
|
|
49
|
+
"cancellation of the caller."
|
|
50
|
+
),
|
|
51
|
+
remediation=(
|
|
52
|
+
"Re-raise after cleanup. If child cancellation is an expected result, first propagate cancellation "
|
|
53
|
+
"when asyncio.current_task().cancelling() is nonzero. Use an exact suppression for a documented "
|
|
54
|
+
"boundary that deliberately consumes cancellation."
|
|
55
|
+
),
|
|
56
|
+
category=RuleCategory.CORRECTNESS,
|
|
57
|
+
autofix=AutofixPolicy.NONE,
|
|
58
|
+
limitations=(
|
|
59
|
+
"Only explicit, import-proven asyncio.CancelledError handlers in async functions are inspected; broad handlers, contextlib.suppress, tests, support fixtures, and generated files are excluded.",
|
|
60
|
+
"Only straight-line handlers with a return or normal fallthrough are reported. Compound handlers, re-raising, and translated exceptions are excluded. Any attribute call named uncancel is conservatively excluded without proving its receiver, so unrelated methods can hide a finding.",
|
|
61
|
+
"A single-name task created by asyncio.create_task, immediately cancelled before a try that only awaits it, is intentionally excluded as child cleanup; this does not prove concurrent caller cancellation cannot occur.",
|
|
62
|
+
"Try statements with control-transferring or compound finalizers are excluded, including handlers nested beneath them. Runtime helper effects and interprocedural task ownership are not inferred.",
|
|
63
|
+
),
|
|
64
|
+
examples=(
|
|
65
|
+
RuleExample(
|
|
66
|
+
example_id="cancelled-operation-returns-success",
|
|
67
|
+
title="Cancellation becomes a successful return",
|
|
68
|
+
outcome=ExampleOutcome.MATCH,
|
|
69
|
+
files=(
|
|
70
|
+
ExampleFile.python(
|
|
71
|
+
"worker.py",
|
|
72
|
+
"import asyncio\n\nasync def worker():\n try:\n await asyncio.sleep(10)\n except asyncio.CancelledError:\n return None\n",
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
focus_path=PurePosixPath("worker.py"),
|
|
76
|
+
expected_count=1,
|
|
77
|
+
public=True,
|
|
78
|
+
),
|
|
79
|
+
RuleExample(
|
|
80
|
+
example_id="cancelled-operation-reraises",
|
|
81
|
+
title="Cleanup preserves cancellation",
|
|
82
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
83
|
+
files=(
|
|
84
|
+
ExampleFile.python(
|
|
85
|
+
"worker.py",
|
|
86
|
+
"import asyncio\n\nasync def worker():\n try:\n await asyncio.sleep(10)\n except asyncio.CancelledError:\n raise\n",
|
|
87
|
+
),
|
|
88
|
+
),
|
|
89
|
+
focus_path=PurePosixPath("worker.py"),
|
|
90
|
+
expected_count=0,
|
|
91
|
+
public=True,
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
description = documentation.summary
|
|
96
|
+
|
|
97
|
+
@override
|
|
98
|
+
def check_context(self, context: PythonFileContext) -> list[Diagnostic]:
|
|
99
|
+
if "CancelledError" not in context.source:
|
|
100
|
+
return []
|
|
101
|
+
if context.generated or is_test_path(context.path) or is_test_support_path(context.path):
|
|
102
|
+
return []
|
|
103
|
+
tree = context.tree
|
|
104
|
+
if tree is None:
|
|
105
|
+
return []
|
|
106
|
+
diagnostics: list[Diagnostic] = []
|
|
107
|
+
for function in nodes(tree, ast.AsyncFunctionDef, index=context.node_index):
|
|
108
|
+
diagnostics.extend(_function_diagnostics(function, context, self.code))
|
|
109
|
+
return sorted(diagnostics, key=lambda item: (item.line, item.col))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _function_diagnostics(function: ast.AsyncFunctionDef, context: PythonFileContext, code: str) -> list[Diagnostic]:
|
|
113
|
+
local = tuple(_local_nodes(function))
|
|
114
|
+
children = _owned_tasks(local, context.imports)
|
|
115
|
+
previous = _preceding_statements(local)
|
|
116
|
+
diagnostics: list[Diagnostic] = []
|
|
117
|
+
for node in local:
|
|
118
|
+
if not isinstance(node, ast.Try) or _owned_child_cleanup(node, previous.get(id(node)), children):
|
|
119
|
+
continue
|
|
120
|
+
for handler in node.handlers:
|
|
121
|
+
if not _catches_cancellation(handler.type, context.imports) or not _normal_exit(handler.body):
|
|
122
|
+
continue
|
|
123
|
+
if is_suppressed(context.source_lines, handler.lineno, code):
|
|
124
|
+
continue
|
|
125
|
+
diagnostics.append(
|
|
126
|
+
Diagnostic(
|
|
127
|
+
path=context.path,
|
|
128
|
+
line=handler.lineno,
|
|
129
|
+
col=handler.col_offset + 1,
|
|
130
|
+
code=code,
|
|
131
|
+
message=_MESSAGE,
|
|
132
|
+
severity=Severity.WARNING,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
return diagnostics
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _local_nodes(function: ast.AsyncFunctionDef) -> Iterator[ast.AST]:
|
|
139
|
+
stack: list[ast.AST] = list(reversed(function.body))
|
|
140
|
+
yield function
|
|
141
|
+
while stack:
|
|
142
|
+
node = stack.pop()
|
|
143
|
+
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.Lambda):
|
|
144
|
+
continue
|
|
145
|
+
if isinstance(node, ast.Try | ast.TryStar) and any(
|
|
146
|
+
not isinstance(statement, _SIMPLE_STATEMENTS) for statement in node.finalbody
|
|
147
|
+
):
|
|
148
|
+
continue
|
|
149
|
+
yield node
|
|
150
|
+
stack.extend(reversed(list(ast.iter_child_nodes(node))))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _catches_cancellation(exception: ast.expr | None, imports: ImportIndex) -> bool:
|
|
154
|
+
if isinstance(exception, ast.Tuple):
|
|
155
|
+
return any(_catches_cancellation(item, imports) for item in exception.elts)
|
|
156
|
+
return exception is not None and imports.resolves(exception, sources=_ASYNCIO, symbol="CancelledError")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _normal_exit(statements: list[ast.stmt]) -> bool:
|
|
160
|
+
for statement in statements:
|
|
161
|
+
if any(
|
|
162
|
+
isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "uncancel"
|
|
163
|
+
for node in ast.walk(statement)
|
|
164
|
+
):
|
|
165
|
+
return False
|
|
166
|
+
if isinstance(statement, ast.Return):
|
|
167
|
+
return True
|
|
168
|
+
if not isinstance(statement, _SIMPLE_STATEMENTS):
|
|
169
|
+
return False
|
|
170
|
+
return True
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _owned_tasks(local: tuple[ast.AST, ...], imports: ImportIndex) -> frozenset[str]:
|
|
174
|
+
bindings: dict[str, list[ast.AST]] = {}
|
|
175
|
+
for node in local:
|
|
176
|
+
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store | ast.Del):
|
|
177
|
+
bindings.setdefault(node.id, []).append(node)
|
|
178
|
+
if (
|
|
179
|
+
isinstance(node, ast.Attribute)
|
|
180
|
+
and isinstance(node.ctx, ast.Store | ast.Del)
|
|
181
|
+
and isinstance(node.value, ast.Name)
|
|
182
|
+
):
|
|
183
|
+
bindings.setdefault(node.value.id, []).append(node)
|
|
184
|
+
return frozenset(
|
|
185
|
+
node.targets[0].id
|
|
186
|
+
for node in local
|
|
187
|
+
if isinstance(node, ast.Assign)
|
|
188
|
+
and len(node.targets) == 1
|
|
189
|
+
and isinstance(node.targets[0], ast.Name)
|
|
190
|
+
and len(bindings[node.targets[0].id]) == 1
|
|
191
|
+
and isinstance(node.value, ast.Call)
|
|
192
|
+
and imports.resolves(node.value.func, sources=frozenset({"asyncio"}), symbol="create_task")
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _preceding_statements(local: tuple[ast.AST, ...]) -> dict[int, ast.stmt]:
|
|
197
|
+
previous: dict[int, ast.stmt] = {}
|
|
198
|
+
for node in local:
|
|
199
|
+
for suite in _statement_suites(node):
|
|
200
|
+
for first, second in pairwise(suite):
|
|
201
|
+
previous[id(second)] = first
|
|
202
|
+
return previous
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _owned_child_cleanup(node: ast.Try, previous: ast.stmt | None, children: frozenset[str]) -> bool:
|
|
206
|
+
if len(node.body) != 1:
|
|
207
|
+
return False
|
|
208
|
+
match node.body[0], previous:
|
|
209
|
+
case (
|
|
210
|
+
ast.Expr(value=ast.Await(value=ast.Name(id=awaited))),
|
|
211
|
+
ast.Expr(value=ast.Call(func=ast.Attribute(value=ast.Name(id=cancelled), attr="cancel"))),
|
|
212
|
+
):
|
|
213
|
+
return awaited == cancelled and awaited in children
|
|
214
|
+
case _:
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _statement_suites(node: ast.AST) -> tuple[list[ast.stmt], ...]:
|
|
219
|
+
match node:
|
|
220
|
+
case ast.Try() | ast.TryStar():
|
|
221
|
+
return node.body, node.orelse, node.finalbody
|
|
222
|
+
case ast.If() | ast.For() | ast.AsyncFor() | ast.While():
|
|
223
|
+
return node.body, node.orelse
|
|
224
|
+
case ast.AsyncFunctionDef() | ast.With() | ast.AsyncWith() | ast.ExceptHandler() | ast.match_case():
|
|
225
|
+
return (node.body,)
|
|
226
|
+
case _:
|
|
227
|
+
return ()
|
|
@@ -168,7 +168,7 @@ def _candidate(
|
|
|
168
168
|
identity = f"{spec.symbol.module}.{spec.symbol.name}"
|
|
169
169
|
if not _unconditional(context, node, function) or not _subject_called(context, function, node):
|
|
170
170
|
return identity, None
|
|
171
|
-
blocked = (available.blocked - aliases.keys()) | changed_before(function, node)
|
|
171
|
+
blocked = (available.blocked - aliases.keys()) | changed_before(function, node, aliases)
|
|
172
172
|
scope = WiringScope(available.parameters, aliases, frozenset(blocked))
|
|
173
173
|
arguments = wiring(context, project, node, spec, scope)
|
|
174
174
|
return identity, f"{identity}:{arguments!r}" if arguments is not None else None
|