sarj-python-lint 1.3.0__tar.gz → 1.3.1__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.
Files changed (151) hide show
  1. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/PKG-INFO +1 -1
  2. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/pyproject.toml +1 -1
  3. sarj_python_lint-1.3.1/src/sarj_python_lint/rules/_set_fields.py +110 -0
  4. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +26 -8
  5. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +71 -4
  6. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +27 -9
  7. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/repeated_test_composition.py +18 -11
  8. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/unused_test_factory_option.py +24 -4
  9. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/.gitignore +0 -0
  10. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/LICENSE +0 -0
  11. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/README.md +0 -0
  12. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/__init__.py +0 -0
  13. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/__main__.py +0 -0
  14. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/_analysis_session.py +0 -0
  15. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/_file_context.py +0 -0
  16. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/_filesystem.py +0 -0
  17. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/_python_target.py +0 -0
  18. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  19. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/_secret_names.py +0 -0
  20. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/_version.py +0 -0
  21. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/benchmark.py +0 -0
  22. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/json_boundary.py +0 -0
  23. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/py.typed +0 -0
  24. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/ratchet.py +0 -0
  25. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rule_base.py +0 -0
  26. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/__init__.py +0 -0
  27. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
  28. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  29. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/_ast_position.py +0 -0
  30. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/_comments.py +0 -0
  31. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  32. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  33. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/_first_party.py +0 -0
  34. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  35. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/_imports.py +0 -0
  36. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/_logging.py +0 -0
  37. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/_nominal_project.py +0 -0
  38. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/_paths.py +0 -0
  39. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/_project_index.py +0 -0
  40. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  41. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/_pytest.py +0 -0
  42. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/_registry.py +0 -0
  43. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/_sql.py +0 -0
  44. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  45. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  46. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/_test_composition.py +0 -0
  47. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +0 -0
  48. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  49. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  50. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  51. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  52. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  53. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  54. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
  55. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  56. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  57. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  58. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  59. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
  60. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  61. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  62. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
  63. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  64. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  65. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  66. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  67. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
  68. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  69. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  70. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  71. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  72. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  73. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  74. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
  75. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  76. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  77. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
  78. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  79. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  80. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  81. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  82. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  83. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  84. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  85. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  86. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  87. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  88. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  89. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  90. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  91. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  92. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  93. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  94. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
  95. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  96. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  97. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  98. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  99. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
  100. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  101. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  102. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  103. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  104. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  105. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  106. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  107. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  108. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  109. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  110. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  111. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  112. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
  113. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  114. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  115. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  116. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
  117. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  118. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  119. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  120. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  121. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
  122. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  123. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  124. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  125. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  126. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  127. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  128. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  129. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  130. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  131. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  132. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  133. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  134. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  135. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  136. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  137. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  138. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  139. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  140. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  141. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
  142. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  143. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  144. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/stepdown.py +0 -0
  145. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  146. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  147. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  148. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  149. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  150. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  151. {sarj_python_lint-1.3.0 → sarj_python_lint-1.3.1}/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.0
3
+ Version: 1.3.1
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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "1.3.0"
3
+ version = "1.3.1"
4
4
  description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "sarj-ai" }]
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from typing import TYPE_CHECKING
5
+
6
+ from sarj_python_lint.rules._ast_index import walk as walk_ast
7
+ from sarj_python_lint.rules._imports import ImportIndex
8
+
9
+
10
+ if TYPE_CHECKING:
11
+ from sarj_python_lint._file_context import PythonFileContext
12
+ from sarj_python_lint.rules._project_index import ClassSummary, ProjectIndexSet, SourceUnit
13
+
14
+
15
+ def declared_set_fields(
16
+ function: ast.FunctionDef | ast.AsyncFunctionDef, project: ProjectIndexSet, context: PythonFileContext
17
+ ) -> set[str]:
18
+ changed = _changed_roots(function)
19
+ fields: set[str] = set()
20
+ for argument in (*function.args.posonlyargs, *function.args.args, *function.args.kwonlyargs):
21
+ if argument.arg in changed or argument.annotation is None:
22
+ continue
23
+ summary = _field_class(argument.annotation, project, context)
24
+ if summary is None:
25
+ continue
26
+ owner = project.source_unit(summary.symbol.module)
27
+ if owner is None or owner.tree is None:
28
+ continue
29
+ for name, annotation in summary.fields.items():
30
+ if _builtin_set_annotation(annotation, owner) and _plain_field(owner, summary.symbol.name, name):
31
+ fields.add(f"{argument.arg}.{name}")
32
+ return fields
33
+
34
+
35
+ def _field_class(annotation: ast.expr, project: ProjectIndexSet, context: PythonFileContext) -> ClassSummary | None:
36
+ root = annotation
37
+ while isinstance(root, ast.Attribute):
38
+ root = root.value
39
+ if not isinstance(root, ast.Name) or any(
40
+ node.id == root.id and isinstance(node.ctx, (ast.Store, ast.Del)) for node in context.nodes(ast.Name)
41
+ ):
42
+ return None
43
+ qualified = context.module_imports.resolved_qualified_name(annotation)
44
+ unit = project.unit(context.path)
45
+ if qualified is not None:
46
+ module, _, name = qualified.rpartition(".")
47
+ unit = project.source_unit(module)
48
+ annotation = ast.Name(id=name, ctx=ast.Load())
49
+ return project.class_for(unit, annotation) if unit is not None else None
50
+
51
+
52
+ def _builtin_set_annotation(annotation: ast.expr, unit: SourceUnit) -> bool:
53
+ if unit.tree is None:
54
+ return False
55
+ base = annotation.value if isinstance(annotation, ast.Subscript) else annotation
56
+ return (
57
+ isinstance(base, ast.Name)
58
+ and base.id in {"set", "frozenset"}
59
+ and ImportIndex.from_tree(unit.tree, module_scope_only=True).builtin_is_unshadowed(base.id)
60
+ )
61
+
62
+
63
+ def _plain_field(unit: SourceUnit, class_name: str, name: str) -> bool:
64
+ if unit.tree is None:
65
+ return False
66
+ owner = next((node for node in unit.tree.body if isinstance(node, ast.ClassDef) and node.name == class_name), None)
67
+ if owner is None:
68
+ return False
69
+ for statement in owner.body:
70
+ if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)) and statement.name in {
71
+ name,
72
+ "__getattribute__",
73
+ "__getattr__",
74
+ }:
75
+ return False
76
+ if isinstance(statement, ast.Assign) and any(
77
+ isinstance(target, ast.Name) and target.id in {name, "set", "frozenset"} for target in statement.targets
78
+ ):
79
+ return False
80
+ return True
81
+
82
+
83
+ def _changed_roots(function: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]:
84
+ changed: set[str] = set()
85
+ for node in (node for statement in function.body for node in walk_ast(statement)):
86
+ match node:
87
+ case ast.Name(id=name, ctx=ast.Store() | ast.Del()):
88
+ changed.add(name)
89
+ case ast.Attribute(ctx=ast.Store() | ast.Del()):
90
+ root = node.value
91
+ while isinstance(root, ast.Attribute):
92
+ root = root.value
93
+ if isinstance(root, ast.Name):
94
+ changed.add(root.id)
95
+ case ast.Import(names=aliases) | ast.ImportFrom(names=aliases):
96
+ changed.update(alias.asname or alias.name.split(".")[0] for alias in aliases)
97
+ case (
98
+ ast.ExceptHandler(name=str(name))
99
+ | ast.MatchAs(name=str(name))
100
+ | ast.MatchStar(name=str(name))
101
+ | ast.MatchMapping(rest=str(name))
102
+ | ast.arg(arg=name)
103
+ | ast.FunctionDef(name=name)
104
+ | ast.AsyncFunctionDef(name=name)
105
+ | ast.ClassDef(name=name)
106
+ ):
107
+ changed.add(name)
108
+ case _:
109
+ pass
110
+ return changed
@@ -91,7 +91,7 @@ class NoHiddenConstructorFallback(Rule):
91
91
  remediation="Require the constructor argument and resolve any default at the composition root or call site.",
92
92
  category=RuleCategory.ARCHITECTURE,
93
93
  limitations=(
94
- "Detection requires a proven local settings provider, an optional parameter defaulting to None, and a first-party composition call.",
94
+ "Detection requires a proven local settings provider and a first-party composition call; covers None fallbacks and settings attributes captured directly in module-level class constructor defaults.",
95
95
  "Tests, generated files, migrations, descriptors, library environment fallbacks, and unconstructed classes are excluded.",
96
96
  ),
97
97
  examples=(
@@ -186,7 +186,8 @@ class NoHiddenConstructorFallback(Rule):
186
186
  )
187
187
  if init is None or _is_descriptor(init):
188
188
  continue
189
- hidden = _hidden_parameters(init, resolver)
189
+ default_shadowed = _default_scope(class_node, init) if class_node in tree.body else None
190
+ hidden = _hidden_parameters(init, resolver, default_shadowed)
190
191
  if not hidden or not _has_composition_call(path, class_node.name, first_party, facts):
191
192
  continue
192
193
  diagnostics.append(
@@ -208,23 +209,38 @@ def _is_descriptor(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
208
209
  return any(_tail(decorator) in _DESCRIPTOR_DECORATORS for decorator in node.decorator_list)
209
210
 
210
211
 
212
+ def _default_scope(owner: ast.ClassDef, init: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]:
213
+ collector = _LocalBindingCollector()
214
+ for statement in owner.body:
215
+ if statement is init:
216
+ break
217
+ collector.visit(statement)
218
+ return collector.names
219
+
220
+
211
221
  def _hidden_parameters(
212
222
  init: ast.FunctionDef | ast.AsyncFunctionDef,
213
223
  resolver: _RuntimeConfigResolver,
224
+ default_shadowed: set[str] | None = None,
214
225
  ) -> list[_HiddenParameter]:
215
226
  positional = (*init.args.posonlyargs, *init.args.args)
216
227
  defaulted_positional = positional[-len(init.args.defaults) :] if init.args.defaults else ()
217
228
  positional_defaults = zip(defaulted_positional, init.args.defaults, strict=True)
229
+ defaults = (*positional_defaults, *zip(init.args.kwonlyargs, init.args.kw_defaults, strict=True))
230
+ captured = [
231
+ _HiddenParameter(parameter, uses_boolean_or=False)
232
+ for parameter, default in defaults
233
+ if default is not None
234
+ and default_shadowed is not None
235
+ and resolver.is_runtime_config(default, default_shadowed)
236
+ ]
218
237
  candidates = {
219
238
  parameter.arg: parameter
220
- for parameter, default in (
221
- *positional_defaults,
222
- *zip(init.args.kwonlyargs, init.args.kw_defaults, strict=True),
223
- )
239
+ for parameter, default in defaults
224
240
  if isinstance(default, ast.Constant) and default.value is None
225
241
  }
226
242
  if not candidates:
227
- return []
243
+ return captured
228
244
 
229
245
  hidden: dict[str, bool] = {}
230
246
  rebound: set[str] = set()
@@ -239,7 +255,9 @@ def _hidden_parameters(
239
255
  hidden[name] = hidden.get(name, False) or uses_boolean_or
240
256
  rebound.update(_directly_bound_names(statement) & candidates.keys())
241
257
  shadowed.update(_directly_bound_names(statement))
242
- return [_HiddenParameter(parameter, hidden[name]) for name, parameter in candidates.items() if name in hidden]
258
+ return captured + [
259
+ _HiddenParameter(parameter, hidden[name]) for name, parameter in candidates.items() if name in hidden
260
+ ]
243
261
 
244
262
 
245
263
  def _statement_fallbacks(
@@ -57,7 +57,7 @@ class PreferInjectedDependencyOverMonkeypatch(Rule):
57
57
  autofix=AutofixPolicy.NONE,
58
58
  limitations=(
59
59
  "Only maintained test paths are analyzed; generated files and production helpers are excluded.",
60
- "The rule recognizes pytest monkeypatch and statically resolved unittest.mock or pytest-mock patch APIs.",
60
+ "The rule recognizes pytest monkeypatch fixtures, unambiguous local MonkeyPatch constructors and context handles, and statically resolved unittest.mock or pytest-mock patch APIs.",
61
61
  "Environment, mapping, import-path, and working-directory mutations are intentionally allowed.",
62
62
  "Untyped handles are recognized only on pytest tests or fixtures; directly parametrized values are excluded.",
63
63
  "A nested function that captures a monkeypatch handle from an outer scope is not inferred.",
@@ -109,10 +109,13 @@ class PreferInjectedDependencyOverMonkeypatch(Rule):
109
109
  return []
110
110
  imports = context.module_imports
111
111
  parents = parent_map(tree, index=context.node_index)
112
+ has_factory = "MonkeyPatch" in context.source and any(
113
+ _creates_monkeypatch(call, imports) for call in context.nodes(ast.Call)
114
+ )
112
115
  replacements = [
113
116
  (call, f"monkeypatch.{_operation(call)}")
114
117
  for function in _functions(tree, node_index=context.node_index)
115
- for call in _attribute_mutations(function, imports, parents)
118
+ for call in _attribute_mutations(function, imports, parents, has_factory=has_factory)
116
119
  ]
117
120
  replacements.extend((call, label) for call, label in _patch_calls(tree, imports, node_index=context.node_index))
118
121
  diagnostics = [
@@ -264,9 +267,11 @@ def _attribute_mutations(
264
267
  function: ast.FunctionDef | ast.AsyncFunctionDef,
265
268
  imports: ImportIndex,
266
269
  parents: Mapping[ast.AST, ast.AST],
270
+ *,
271
+ has_factory: bool,
267
272
  ) -> list[ast.Call]:
268
273
  parameters = _parameter_handles(function, imports, parents)
269
- if not parameters:
274
+ if not parameters and not has_factory:
270
275
  return []
271
276
  nodes = tuple(_lexical_body_nodes(function))
272
277
  handles = set(parameters)
@@ -278,11 +283,73 @@ def _attribute_mutations(
278
283
  and isinstance(node.func, ast.Attribute)
279
284
  and node.func.attr in _ATTRIBUTE_MUTATIONS
280
285
  and isinstance(node.func.value, ast.Name)
281
- and node.func.value.id in handles
286
+ and (
287
+ node.func.value.id in handles
288
+ or _constructed_reference(
289
+ node.func.value.id, node, function, nodes=nodes, imports=imports, parents=parents, seen=frozenset()
290
+ )
291
+ )
282
292
  and not _parameter_rebound_before(node, node.func.value.id, nodes, parameters)
283
293
  ]
284
294
 
285
295
 
296
+ def _creates_monkeypatch(call: ast.Call, imports: ImportIndex) -> bool:
297
+ callee = call.func
298
+ if isinstance(callee, ast.Attribute) and callee.attr == "context":
299
+ callee = callee.value
300
+ return imports.resolves(callee, sources=_PYTEST, symbol="MonkeyPatch")
301
+
302
+
303
+ def _constructed_reference(
304
+ name: str,
305
+ use: ast.AST,
306
+ function: ast.FunctionDef | ast.AsyncFunctionDef,
307
+ *,
308
+ nodes: tuple[ast.AST, ...],
309
+ imports: ImportIndex,
310
+ parents: Mapping[ast.AST, ast.AST],
311
+ seen: frozenset[str],
312
+ ) -> bool:
313
+ if name in seen or name in {arg.arg for arg in _arguments(function.args)}:
314
+ return False
315
+ definitions = [node for node in nodes if name in _bound_targets(node)]
316
+ if len(definitions) != 1 or _position(definitions[0]) >= _position(use):
317
+ return False
318
+ definition = definitions[0]
319
+ value: ast.expr | None = None
320
+ if isinstance(definition, (ast.Assign, ast.AnnAssign)) and parents.get(definition) is function:
321
+ value = definition.value
322
+ elif isinstance(definition, ast.withitem) and _within(use, parents.get(definition), parents):
323
+ expression = definition.context_expr
324
+ if (
325
+ isinstance(expression, ast.Call)
326
+ and isinstance(expression.func, ast.Attribute)
327
+ and expression.func.attr == "context"
328
+ ):
329
+ value = expression.func.value
330
+ if imports.resolves(value, sources=_PYTEST, symbol="MonkeyPatch"):
331
+ value = ast.Call(func=value, args=[], keywords=[])
332
+ if isinstance(value, ast.Name):
333
+ return _constructed_reference(
334
+ value.id, definition, function, nodes=nodes, imports=imports, parents=parents, seen=seen | {name}
335
+ )
336
+ return (
337
+ isinstance(value, ast.Call)
338
+ and imports.resolves(value.func, sources=_PYTEST, symbol="MonkeyPatch")
339
+ and not _import_shadowed(value.func, parents, {})
340
+ )
341
+
342
+
343
+ def _within(node: ast.AST, ancestor: ast.AST | None, parents: Mapping[ast.AST, ast.AST]) -> bool:
344
+ if ancestor is None:
345
+ return False
346
+ while (parent := parents.get(node)) is not None:
347
+ if parent is ancestor:
348
+ return True
349
+ node = parent
350
+ return False
351
+
352
+
286
353
  def _parameter_handles(
287
354
  function: ast.FunctionDef | ast.AsyncFunctionDef,
288
355
  imports: ImportIndex,
@@ -9,7 +9,7 @@ from sarj_python_lint.rule_base import (
9
9
  Diagnostic,
10
10
  ExampleFile,
11
11
  ExampleOutcome,
12
- Rule,
12
+ ProjectRule,
13
13
  RuleCategory,
14
14
  RuleDocumentation,
15
15
  RuleExample,
@@ -17,6 +17,8 @@ from sarj_python_lint.rule_base import (
17
17
  is_suppressed,
18
18
  )
19
19
  from sarj_python_lint.rules._ast_index import walk as walk_ast
20
+ from sarj_python_lint.rules._project_index import ProjectIndexSet
21
+ from sarj_python_lint.rules._set_fields import declared_set_fields
20
22
 
21
23
 
22
24
  if TYPE_CHECKING:
@@ -45,7 +47,7 @@ _TRACKED_BUILTINS = frozenset(
45
47
 
46
48
 
47
49
  @final
48
- class PreferSetIsdisjoint(Rule):
50
+ class PreferSetIsdisjoint(ProjectRule):
49
51
  id = "prefer-set-isdisjoint"
50
52
  code = "SARJ431"
51
53
  documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
@@ -56,19 +58,19 @@ class PreferSetIsdisjoint(Rule):
56
58
  category=RuleCategory.STYLE,
57
59
  autofix=AutofixPolicy.SUGGESTION,
58
60
  limitations=(
59
- "Built-in set identity must be proven from a literal, comprehension, constructor, or one dominating local assignment.",
60
- "For binary `&`, the left operand must be a proven built-in set and the right operand must be a literal, comprehension, built-in iterable constructor, standard no-argument collection view, or proven set; annotations, parameters, attributes, subclasses, branch-merged bindings, stored intersections, and generated files are excluded.",
61
+ "Set receivers must come from a literal, comprehension, constructor, dominating local assignment, or an unchanged parameter's directly declared first-party set/frozenset field.",
62
+ "Declared fields rely on the annotated contract. Unknown or union field types, properties, explicit owner/field reassignment, branch-merged bindings, stored intersections, and generated files are excluded. The other operand must be a known iterable.",
61
63
  "The suggestion is intentionally not an autofix because short-circuiting may make custom element equality or hashing side effects observable.",
62
64
  ),
63
65
  examples=(
64
66
  RuleExample(
65
67
  example_id="discarded-intersection",
66
- title="Set intersection is used only for an emptiness test",
68
+ title="A declared set field is used only for an overlap test",
67
69
  outcome=ExampleOutcome.MATCH,
68
70
  files=(
69
71
  ExampleFile.python(
70
72
  "app/policy.py",
71
- "allowed = {'read', 'write'}\nrequested = set(scopes)\nif not (allowed & requested):\n deny()\n",
73
+ "class AccessCase:\n tags: frozenset[str]\n\ndef accepts(case: AccessCase):\n if case.tags & {'read', 'write'}:\n allow()\n",
72
74
  ),
73
75
  ),
74
76
  focus_path=PurePosixPath("app/policy.py"),
@@ -102,7 +104,12 @@ class PreferSetIsdisjoint(Rule):
102
104
  tree = context.tree
103
105
  if tree is None:
104
106
  return []
105
- scanner = _Scanner(path, context.source_lines, _shadowed_builtins(tree, node_index=context.node_index))
107
+ project = context.session.project or ProjectIndexSet.single(path, source)
108
+ fields = {
109
+ node: declared_set_fields(node, project, context)
110
+ for node in context.nodes(ast.FunctionDef, ast.AsyncFunctionDef)
111
+ }
112
+ scanner = _Scanner(path, context.source_lines, _shadowed_builtins(tree, node_index=context.node_index), fields)
106
113
  scanner.scan_body(tree.body, set())
107
114
  scanner.diagnostics.sort(key=lambda item: (item.line, item.col))
108
115
  return scanner.diagnostics
@@ -110,12 +117,19 @@ class PreferSetIsdisjoint(Rule):
110
117
 
111
118
  @final
112
119
  class _Scanner:
113
- def __init__(self, path: Path, source_lines: list[str], shadowed: frozenset[str]) -> None:
120
+ def __init__(
121
+ self,
122
+ path: Path,
123
+ source_lines: list[str],
124
+ shadowed: frozenset[str],
125
+ fields: dict[ast.FunctionDef | ast.AsyncFunctionDef, set[str]],
126
+ ) -> None:
114
127
  self.path = path
115
128
  self.source_lines = source_lines
116
129
  self.shadowed = shadowed
117
130
  self.diagnostics: list[Diagnostic] = []
118
131
  self.reported: set[int] = set()
132
+ self.fields = fields
119
133
 
120
134
  def scan_body(self, body: list[ast.stmt], exact: set[str]) -> None:
121
135
  local = set(exact)
@@ -139,7 +153,9 @@ class _Scanner:
139
153
  case ast.Assert(test=test):
140
154
  self._scan_boolean(test, exact)
141
155
  self._scan_embedded(test, exact)
142
- case ast.FunctionDef() | ast.AsyncFunctionDef() | ast.ClassDef():
156
+ case ast.FunctionDef() | ast.AsyncFunctionDef():
157
+ self.scan_body(statement.body, self.fields.get(statement, set()))
158
+ case ast.ClassDef():
143
159
  self.scan_body(statement.body, set())
144
160
  case ast.For() | ast.AsyncFor():
145
161
  self._scan_embedded(statement.iter, exact)
@@ -292,6 +308,8 @@ def _is_builtin_bool_call(node: ast.expr, shadowed: frozenset[str]) -> bool:
292
308
 
293
309
 
294
310
  def _is_exact_set(node: ast.expr, exact: set[str], shadowed: frozenset[str]) -> bool:
311
+ if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name):
312
+ return f"{node.value.id}.{node.attr}" in exact
295
313
  if isinstance(node, ast.Set | ast.SetComp):
296
314
  return True
297
315
  if isinstance(node, ast.Name):
@@ -61,7 +61,7 @@ class RepeatedTestComposition(ProjectRule):
61
61
  "Requires three sibling tests with equivalent wiring of at least two stored and invoked first-party ABC/Protocol dependencies.",
62
62
  "Collaborators must resolve to equivalent fixture parameters or first-party constructor expressions, optionally through unambiguous local aliases.",
63
63
  "Requires an unconditional construction followed by a subject method call; dynamic arguments, parametrized tests, constructor validation, multiple instances, mutated dependencies, fixtures, helpers, generated files, and inherited test classes are excluded.",
64
- "HTTPX detection requires a managed AsyncClient with an inline ASGITransport and equivalent options except its app.",
64
+ "HTTPX detection requires a managed AsyncClient with an inline or unchanged local ASGITransport and equivalent options except its app; unambiguous constructor aliases are accepted.",
65
65
  ),
66
66
  examples=(
67
67
  RuleExample(
@@ -159,8 +159,11 @@ def _candidate(
159
159
  callee = unalias(node.func, aliases)
160
160
  if shadowed(callee, available.parameters | available.blocked):
161
161
  return None
162
- if context.imports.resolves(node.func, sources=_HTTPX, symbol="AsyncClient"):
163
- key = _client_key(context, node) if _unconditional(context, node, function) else None
162
+ if context.imports.resolves(callee, sources=_HTTPX, symbol="AsyncClient"):
163
+ blocked = available.parameters | available.blocked
164
+ changed = changed_before(function, node, aliases)
165
+ stable = {name: value for name, value in aliases.items() if name not in changed}
166
+ key = _client_key(context, node, stable, blocked) if _unconditional(context, node, function) else None
164
167
  return "httpx.AsyncClient", key
165
168
  spec = constructor(context, callee, project)
166
169
  if spec is None:
@@ -238,9 +241,9 @@ def _subject_called(context: PythonFileContext, function: Function, call: ast.Ca
238
241
  )
239
242
 
240
243
 
241
- def _client_key(context: PythonFileContext, call: ast.Call) -> str | None:
242
- if not context.imports.resolves(call.func, sources=_HTTPX, symbol="AsyncClient"):
243
- return None
244
+ def _client_key(
245
+ context: PythonFileContext, call: ast.Call, aliases: dict[str, ast.expr], blocked: frozenset[str]
246
+ ) -> str | None:
244
247
  parent = context.parents.get(call)
245
248
  if (
246
249
  not isinstance(parent, ast.withitem)
@@ -259,7 +262,7 @@ def _client_key(context: PythonFileContext, call: ast.Call) -> str | None:
259
262
  return None
260
263
  options.append((keyword.arg, ast.dump(keyword.value)))
261
264
  continue
262
- transport_options = _transport_options(context, keyword.value)
265
+ transport_options = _transport_options(context, unalias(keyword.value, aliases), aliases, blocked)
263
266
  if transport_options is None:
264
267
  return None
265
268
  found_transport = True
@@ -267,10 +270,14 @@ def _client_key(context: PythonFileContext, call: ast.Call) -> str | None:
267
270
  return repr(sorted(options)) if found_transport else None
268
271
 
269
272
 
270
- def _transport_options(context: PythonFileContext, transport: ast.expr) -> list[tuple[str, str]] | None:
271
- if not isinstance(transport, ast.Call) or not context.imports.resolves(
272
- transport.func, sources=_HTTPX, symbol="ASGITransport"
273
- ):
273
+ def _transport_options(
274
+ context: PythonFileContext, transport: ast.expr, aliases: dict[str, ast.expr], blocked: frozenset[str]
275
+ ) -> list[tuple[str, str]] | None:
276
+ if not isinstance(transport, ast.Call):
277
+ return None
278
+ earlier = {name: value for name, value in aliases.items() if value.lineno < transport.lineno}
279
+ callee = unalias(transport.func, earlier)
280
+ if shadowed(callee, blocked) or not context.imports.resolves(callee, sources=_HTTPX, symbol="ASGITransport"):
274
281
  return None
275
282
  if transport.args or any(option.arg is None for option in transport.keywords):
276
283
  return None
@@ -44,7 +44,7 @@ class UnusedTestFactoryOption(Rule):
44
44
  category=RuleCategory.TESTING,
45
45
  autofix=AutofixPolicy.NONE,
46
46
  limitations=(
47
- "Only module-level private _make_ and _build_ helpers consisting of a single return call are considered.",
47
+ "Only module-level private _make_ and _build_ helpers with straight-line local assignments ending in a construction return are considered; control flow and standalone side effects are excluded.",
48
48
  "Only literal defaults with direct, unambiguous calls in this file are reported. Explicit options require at least two calls with the same effective literal value. Decorators, rebinding, callable escapes, reflection, and argument unpacking exclude the helper.",
49
49
  "Cross-module callers cannot be proven absent. Shared helpers require an exact suppression; this advisory never autofixes signatures or changes default evaluation timing.",
50
50
  ),
@@ -109,12 +109,32 @@ def _is_factory(node: ast.stmt) -> TypeGuard[ast.FunctionDef]:
109
109
  and not node.decorator_list
110
110
  and node.args.vararg is None
111
111
  and node.args.kwarg is None
112
- and len(node.body) == 1
113
- and isinstance(node.body[0], ast.Return)
114
- and isinstance(node.body[0].value, ast.Call)
112
+ and _returns_construction(node.body)
115
113
  )
116
114
 
117
115
 
116
+ def _returns_construction(body: list[ast.stmt]) -> bool:
117
+ if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant):
118
+ body = body[1:]
119
+ if not body or not isinstance(body[-1], ast.Return):
120
+ return False
121
+ assigned: dict[str, ast.expr] = {}
122
+ for statement in body[:-1]:
123
+ match statement:
124
+ case ast.Assign(targets=[ast.Name(id=name)], value=value):
125
+ assigned[name] = value
126
+ case ast.AnnAssign(target=ast.Name(id=name), value=value) if value is not None:
127
+ assigned[name] = value
128
+ case _:
129
+ return False
130
+ value = body[-1].value
131
+ seen: set[str] = set()
132
+ while isinstance(value, ast.Name) and value.id not in seen:
133
+ seen.add(value.id)
134
+ value = assigned.get(value.id)
135
+ return isinstance(value, ast.Call)
136
+
137
+
118
138
  def _factory_findings(
119
139
  tree: ast.Module,
120
140
  function: ast.FunctionDef,