sarj-python-lint 1.24.0__tar.gz → 1.25.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.
Files changed (182) hide show
  1. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +222 -122
  4. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/.gitignore +0 -0
  5. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/LICENSE +0 -0
  6. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/README.md +0 -0
  7. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/__init__.py +0 -0
  8. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/__main__.py +0 -0
  9. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/_analysis_session.py +0 -0
  10. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/_file_context.py +0 -0
  11. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/_filesystem.py +0 -0
  12. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/_python_target.py +0 -0
  13. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  14. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/_secret_names.py +0 -0
  15. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/_version.py +0 -0
  16. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/benchmark.py +0 -0
  17. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/json_boundary.py +0 -0
  18. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/py.typed +0 -0
  19. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/ratchet.py +0 -0
  20. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rule_base.py +0 -0
  21. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  22. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
  23. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  24. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_ast_position.py +0 -0
  25. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  26. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_contract_annotation.py +0 -0
  27. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_copy_on_write.py +0 -0
  28. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_dict_key_views.py +0 -0
  29. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  30. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  31. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  32. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  33. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  34. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_local_source.py +0 -0
  35. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  36. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_nominal_project.py +0 -0
  37. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  38. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
  39. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  40. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  41. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_registry.py +0 -0
  42. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_resource_provenance.py +0 -0
  43. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_set_fields.py +0 -0
  44. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  45. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  46. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  47. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_test_composition.py +0 -0
  48. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/_test_provenance.py +0 -0
  49. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/async_cleanup_registered_synchronously.py +0 -0
  50. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +0 -0
  51. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  52. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  53. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  54. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  55. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  56. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  57. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
  58. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  59. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  60. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  61. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  62. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
  63. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  64. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  65. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
  66. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_before_validator_input_mutation.py +0 -0
  67. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_broad_keyword_capture.py +0 -0
  68. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  69. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  70. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  71. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  72. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_database_programmability.py +0 -0
  73. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
  74. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  75. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  76. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  77. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  78. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  79. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  80. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
  81. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  82. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  83. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  84. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_injected_module_loader.py +0 -0
  85. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_input_model_mutation.py +0 -0
  86. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
  87. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_mocked_pydantic_value_object.py +0 -0
  88. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  89. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_nullable_dependency_fallback.py +0 -0
  90. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  91. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  92. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  93. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  94. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  95. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  96. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  97. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  98. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  99. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  100. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  101. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  102. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  103. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  104. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_shallow_container_type_guard.py +0 -0
  105. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  106. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
  107. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  108. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_test_method_grafting.py +0 -0
  109. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  110. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  111. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  112. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_unused_copy_result.py +0 -0
  113. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_unused_underscored_keyword_parameter.py +0 -0
  114. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
  115. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  116. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  117. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  118. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  119. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  120. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_autospec_for_callable_mock.py +0 -0
  121. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  122. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  123. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  124. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  125. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
  126. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  127. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  128. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
  129. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  130. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  131. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  132. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
  133. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_monotonic_for_elapsed_time.py +0 -0
  134. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  135. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_native_string_check.py +0 -0
  136. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  137. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  138. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  139. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
  140. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_pytest_fixture_injection.py +0 -0
  141. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_regex_fullmatch.py +0 -0
  142. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_required_constructor_parameters.py +0 -0
  143. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  144. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  145. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  146. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  147. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  148. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  149. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  150. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  151. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  152. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  153. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  154. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  155. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  156. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  157. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  158. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  159. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
  160. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/require_explicit_contract_implementation.py +0 -0
  161. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/require_explicit_psycopg_transaction.py +0 -0
  162. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/require_explicit_service_contract.py +0 -0
  163. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  164. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  165. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  166. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/require_precise_factory_signature.py +0 -0
  167. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/require_public_dependency_contract.py +0 -0
  168. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  169. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
  170. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  171. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  172. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/shared_mutable_pydantic_factory.py +0 -0
  173. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  174. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  175. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  176. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/subprocess_kill_without_reap.py +0 -0
  177. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  178. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  179. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  180. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  181. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  182. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.0}/src/sarj_python_lint/rules/unused_test_factory_option.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sarj-python-lint
3
- Version: 1.24.0
3
+ Version: 1.25.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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "1.24.0"
3
+ version = "1.25.0"
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" }]
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import ast
4
+ from collections import defaultdict
4
5
  from dataclasses import dataclass
5
6
  from enum import StrEnum
6
7
  from io import StringIO
@@ -21,11 +22,11 @@ from sarj_python_lint.rule_base import (
21
22
  Severity,
22
23
  is_suppressed,
23
24
  )
24
- from sarj_python_lint.rules._ast_index import walk as walk_ast
25
+ from sarj_python_lint.rules._ast_index import children, walk as walk_ast
25
26
 
26
27
 
27
28
  if TYPE_CHECKING:
28
- from collections.abc import Iterator
29
+ from collections.abc import Iterable, Iterator
29
30
 
30
31
  from sarj_python_lint._file_context import PythonFileContext
31
32
  from sarj_python_lint.rules._ast_index import NodeIndex
@@ -74,6 +75,15 @@ class _LoopReplacement:
74
75
  allow_multiline: bool = False
75
76
 
76
77
 
78
+ @dataclass(frozen=True)
79
+ class _OwnerBindings:
80
+ names: dict[str, list[ast.Name | ast.arg]]
81
+ external: frozenset[str]
82
+ local_nodes: frozenset[int]
83
+ captured: frozenset[str]
84
+ observes_namespace: bool
85
+
86
+
77
87
  @final
78
88
  class PreferCollectionComprehension(Rule):
79
89
  id = "prefer-collection-comprehension"
@@ -96,13 +106,13 @@ class PreferCollectionComprehension(Rule):
96
106
  "Only function-local, adjacent empty initializers and one synchronous single-purpose loop are checked.",
97
107
  (
98
108
  "The rule fills gaps left by Ruff PERF401, PERF403, and FURB142: derived dict projections, "
99
- "constructor-valued dict projections, destructured list projections, narrowly filtered computed list candidates, and filtered set "
109
+ "constructor-valued and filtered dict projections, destructured list projections, narrowly filtered computed list candidates, and filtered set "
100
110
  "builders."
101
111
  ),
102
112
  (
103
113
  "Comments, loop-target leakage, try blocks, aliases, complex projections, and replacements that "
104
114
  "do not fit a 120-column line or a simple formatter-style multiline comprehension are excluded. "
105
- "No autofix is offered because dict key/value evaluation order can differ."
115
+ "Namespace observers and collections referenced by nested functions are excluded. No autofix is offered because dict key/value evaluation order can differ."
106
116
  ),
107
117
  (
108
118
  "Attribute projections can invoke properties or descriptors, so reviewers should keep the loop "
@@ -241,32 +251,51 @@ class PreferCollectionComprehension(Rule):
241
251
  source_lines = context.source_lines
242
252
  comments = _comment_lines(source)
243
253
  diagnostics: list[Diagnostic] = []
254
+ set_shadowed = _set_is_shadowed(tree, node_index=context.node_index)
255
+ namespace_observers = _namespace_observer_calls(context)
244
256
  for owner in (node for node in context.nodes(ast.AST) if isinstance(node, _Callable)):
245
- for block in _statement_blocks(owner.body):
246
- for init, loop in pairwise(block):
247
- finding = _candidate(
248
- tree,
249
- owner,
250
- init=init,
251
- loop=loop,
252
- source_lines=source_lines,
253
- comments=comments,
254
- node_index=context.node_index,
255
- )
256
- if finding is None:
257
- continue
258
- diagnostics.append(
259
- Diagnostic(
260
- path=path,
261
- line=loop.lineno,
262
- col=loop.col_offset + 1,
263
- code=self.code,
264
- message=_message(finding),
265
- )
257
+ for finding, loop in _owner_candidates(
258
+ owner, source_lines, comments, namespace_observers, set_shadowed=set_shadowed
259
+ ):
260
+ diagnostics.append(
261
+ Diagnostic(
262
+ path=path,
263
+ line=loop.lineno,
264
+ col=loop.col_offset + 1,
265
+ code=self.code,
266
+ message=_message(finding),
266
267
  )
268
+ )
267
269
  return sorted(diagnostics, key=lambda diagnostic: (diagnostic.line, diagnostic.col))
268
270
 
269
271
 
272
+ def _owner_candidates(
273
+ owner: _Callable,
274
+ source_lines: list[str],
275
+ comments: frozenset[int],
276
+ namespace_observers: frozenset[int],
277
+ *,
278
+ set_shadowed: bool,
279
+ ) -> Iterator[tuple[_Candidate, ast.For]]:
280
+ bindings = None
281
+ for block in _statement_blocks(owner.body):
282
+ for init, loop in pairwise(block):
283
+ if not isinstance(init, _Init) or not isinstance(loop, ast.For) or _initialized_collection(init) is None:
284
+ continue
285
+ if bindings is None:
286
+ bindings = _owner_bindings(owner, namespace_observers)
287
+ finding = _candidate(
288
+ bindings,
289
+ init=init,
290
+ loop=loop,
291
+ source_lines=source_lines,
292
+ comments=comments,
293
+ set_shadowed=set_shadowed,
294
+ )
295
+ if finding is not None:
296
+ yield finding, loop
297
+
298
+
270
299
  def _statement_blocks(body: list[ast.stmt]) -> Iterator[list[ast.stmt]]:
271
300
  yield body
272
301
  for statement in body:
@@ -286,14 +315,13 @@ def _statement_blocks(body: list[ast.stmt]) -> Iterator[list[ast.stmt]]:
286
315
 
287
316
 
288
317
  def _candidate(
289
- tree: ast.Module,
290
- owner: _Callable,
318
+ bindings: _OwnerBindings,
291
319
  *,
292
320
  init: ast.stmt,
293
321
  loop: ast.stmt,
294
322
  source_lines: list[str],
295
323
  comments: frozenset[int],
296
- node_index: NodeIndex | None = None,
324
+ set_shadowed: bool,
297
325
  ) -> _Candidate | None:
298
326
  if not isinstance(init, _Init) or not isinstance(loop, ast.For) or loop.orelse:
299
327
  return None
@@ -301,7 +329,9 @@ def _candidate(
301
329
  if initialized is None:
302
330
  return None
303
331
  name = initialized.name
304
- if initialized.kind is _CollectionKind.SET and _set_is_shadowed(tree, node_index=node_index):
332
+ if bindings.observes_namespace or name in bindings.captured:
333
+ return None
334
+ if initialized.kind is _CollectionKind.SET and set_shadowed:
305
335
  return None
306
336
  if _has_comment(init, loop, comments) or is_suppressed(
307
337
  source_lines, loop.lineno, PreferCollectionComprehension.code
@@ -310,19 +340,19 @@ def _candidate(
310
340
  bound_names = _bound_names(loop.target)
311
341
  if not bound_names or _contains_starred(loop.target) or name in bound_names:
312
342
  return None
313
- if _target_binding_is_observable(owner, bound_names, loop):
343
+ if _target_binding_is_observable(bindings, bound_names, loop):
314
344
  return None
315
- if (
316
- _loads_name(loop.iter, name)
317
- or _contains_prohibited_expression(loop.iter)
318
- or _declares_external(owner, frozenset({name}))
319
- ):
345
+ if _loads_name(loop.iter, name) or _contains_prohibited_expression(loop.iter) or name in bindings.external:
320
346
  return None
321
347
 
322
- replacement = _loop_replacement(loop, name, initialized.kind, bound_names, owner)
323
- if replacement is None or not _replacement_fits(
324
- init, name, replacement.expression, allow_multiline=replacement.allow_multiline
325
- ):
348
+ try:
349
+ replacement = _loop_replacement(loop, name, initialized.kind, bound_names, bindings)
350
+ fits = replacement is not None and _replacement_fits(
351
+ init, name, replacement.expression, allow_multiline=replacement.allow_multiline
352
+ )
353
+ except RecursionError, SyntaxError:
354
+ return None
355
+ if replacement is None or not fits:
326
356
  return None
327
357
  return _Candidate(kind=replacement.kind, name=name, action=replacement.action)
328
358
 
@@ -354,40 +384,56 @@ def _loop_replacement(
354
384
  name: str,
355
385
  initialized_kind: _CollectionKind,
356
386
  bound_names: frozenset[str],
357
- owner: _Callable,
387
+ bindings: _OwnerBindings,
358
388
  ) -> _LoopReplacement | None:
359
- if initialized_kind is _CollectionKind.DICT and len(loop.body) == 1:
360
- match loop.body[0]:
361
- case ast.Assign(targets=[ast.Subscript(value=ast.Name(id=target), slice=key)], value=value) if (
362
- target == name
363
- ):
364
- if _valid_dict_projection(key, value, name, bound_names):
365
- return _LoopReplacement(
366
- _CollectionKind.DICT,
367
- f"{{{ast.unparse(key)}: {ast.unparse(value)} for {ast.unparse(loop.target)} "
368
- f"in {ast.unparse(loop.iter)}}}",
369
- allow_multiline=isinstance(value, ast.Call),
370
- )
371
- case _:
372
- pass
389
+ if initialized_kind is _CollectionKind.DICT:
390
+ return _dict_replacement(loop, name, bound_names)
373
391
  if initialized_kind is _CollectionKind.LIST:
374
- replacement = _list_replacement(loop, name, bound_names, owner)
392
+ replacement = _list_replacement(loop, name, bound_names, bindings)
375
393
  if replacement is not None:
376
394
  return replacement
377
- if initialized_kind is _CollectionKind.SET and len(loop.body) == 1:
395
+ if initialized_kind is _CollectionKind.SET:
378
396
  return _set_replacement(loop, name, bound_names)
379
397
  return None
380
398
 
381
399
 
400
+ def _dict_replacement(loop: ast.For, name: str, bound_names: frozenset[str]) -> _LoopReplacement | None:
401
+ condition = ""
402
+ filtered = _filtered_statement_parts(loop.body)
403
+ if filtered is not None:
404
+ statement, test, inverted = filtered
405
+ if _loads_name(test, name) or _contains_prohibited_expression(test):
406
+ return None
407
+ condition = f" if {_condition_source(test, inverted=inverted)}"
408
+ elif len(loop.body) == 1:
409
+ statement = loop.body[0]
410
+ else:
411
+ return None
412
+ match statement:
413
+ case ast.Assign(targets=[ast.Subscript(value=ast.Name(id=target), slice=key)], value=value) if target == name:
414
+ pass
415
+ case _:
416
+ return None
417
+ if not _valid_dict_projection(key, value, name, bound_names):
418
+ return None
419
+ return _LoopReplacement(
420
+ _CollectionKind.DICT,
421
+ f"{{{ast.unparse(key)}: {ast.unparse(value)} for {ast.unparse(loop.target)} "
422
+ f"in {_comprehension_expression_source(loop.iter)}{condition}}}",
423
+ "filters and populates" if filtered is not None else "populates",
424
+ allow_multiline=isinstance(value, ast.Call),
425
+ )
426
+
427
+
382
428
  def _list_replacement(
383
- loop: ast.For, name: str, bound_names: frozenset[str], owner: _Callable
429
+ loop: ast.For, name: str, bound_names: frozenset[str], bindings: _OwnerBindings
384
430
  ) -> _LoopReplacement | None:
385
431
  if len(bound_names) > 1 and len(loop.body) == 1:
386
432
  expression = _single_method_argument(loop.body[0], name, "append")
387
433
  if expression is not None and not _loads_name(expression, name) and _simple_projection(expression, bound_names):
388
434
  return _LoopReplacement(
389
435
  _CollectionKind.LIST,
390
- f"[{ast.unparse(expression)} for {ast.unparse(loop.target)} in {ast.unparse(loop.iter)}]",
436
+ f"[{ast.unparse(expression)} for {ast.unparse(loop.target)} in {_comprehension_expression_source(loop.iter)}]",
391
437
  )
392
438
 
393
439
  filtered = _filtered_list_parts(loop.body, name)
@@ -402,32 +448,36 @@ def _list_replacement(
402
448
  condition = _condition_source(test, inverted=inverted)
403
449
  return _LoopReplacement(
404
450
  _CollectionKind.LIST,
405
- f"[{ast.unparse(expression)} for {ast.unparse(loop.target)} in {ast.unparse(loop.iter)} "
451
+ f"[{ast.unparse(expression)} for {ast.unparse(loop.target)} in {_comprehension_expression_source(loop.iter)} "
406
452
  f"if {condition}]",
407
453
  "filters and appends to",
408
454
  allow_multiline=True,
409
455
  )
410
456
 
411
- return _computed_list_replacement(loop, name, bound_names, owner)
457
+ return _computed_list_replacement(loop, name, bound_names, bindings)
412
458
 
413
459
 
414
- def _filtered_list_parts(body: list[ast.stmt], name: str) -> tuple[ast.expr, ast.expr, bool] | None:
460
+ def _filtered_statement_parts(body: list[ast.stmt]) -> tuple[ast.stmt, ast.expr, bool] | None:
415
461
  match body:
416
462
  case [ast.If(test=test, body=[statement], orelse=[])]:
417
- expression = _single_method_argument(statement, name, "append")
418
- if expression is not None:
419
- return expression, test, False
463
+ return statement, test, False
420
464
  case [ast.If(test=test, body=[ast.Continue()], orelse=[]), statement]:
421
- expression = _single_method_argument(statement, name, "append")
422
- if expression is not None:
423
- return expression, test, True
465
+ return statement, test, True
424
466
  case _:
425
- pass
426
- return None
467
+ return None
468
+
469
+
470
+ def _filtered_list_parts(body: list[ast.stmt], name: str) -> tuple[ast.expr, ast.expr, bool] | None:
471
+ parts = _filtered_statement_parts(body)
472
+ if parts is None:
473
+ return None
474
+ statement, test, inverted = parts
475
+ expression = _single_method_argument(statement, name, "append")
476
+ return (expression, test, inverted) if expression is not None else None
427
477
 
428
478
 
429
479
  def _computed_list_replacement(
430
- loop: ast.For, name: str, bound_names: frozenset[str], owner: _Callable
480
+ loop: ast.For, name: str, bound_names: frozenset[str], bindings: _OwnerBindings
431
481
  ) -> _LoopReplacement | None:
432
482
  match loop.body:
433
483
  case [ast.Assign(targets=[ast.Name(id=candidate)], value=value), *tail]:
@@ -437,7 +487,7 @@ def _computed_list_replacement(
437
487
 
438
488
  if candidate == name or candidate in bound_names:
439
489
  return None
440
- if _target_binding_is_observable(owner, frozenset({candidate}), loop):
490
+ if _target_binding_is_observable(bindings, frozenset({candidate}), loop):
441
491
  return None
442
492
  if _loads_name(value, name) or _contains_prohibited_expression(value):
443
493
  return None
@@ -459,7 +509,7 @@ def _computed_list_replacement(
459
509
  condition = assignment if condition_kind == "truthy" else f"{assignment} is not None"
460
510
  return _LoopReplacement(
461
511
  _CollectionKind.LIST,
462
- f"[{ast.unparse(expression)} for {ast.unparse(loop.target)} in {ast.unparse(loop.iter)} if {condition}]",
512
+ f"[{ast.unparse(expression)} for {ast.unparse(loop.target)} in {_comprehension_expression_source(loop.iter)} if {condition}]",
463
513
  "computes, filters, and appends to",
464
514
  allow_multiline=True,
465
515
  )
@@ -517,9 +567,14 @@ def _dotted_name(node: ast.expr) -> bool:
517
567
  return False
518
568
 
519
569
 
570
+ def _comprehension_expression_source(iterable: ast.expr) -> str:
571
+ source = ast.unparse(iterable)
572
+ return f"({source})" if isinstance(iterable, ast.IfExp) else source
573
+
574
+
520
575
  def _condition_source(test: ast.expr, *, inverted: bool) -> str:
521
576
  if not inverted:
522
- return ast.unparse(test)
577
+ return _comprehension_expression_source(test)
523
578
  return ast.unparse(ast.UnaryOp(op=ast.Not(), operand=test))
524
579
 
525
580
 
@@ -597,41 +652,91 @@ def _contains_prohibited_expression(node: ast.AST) -> bool:
597
652
  return any(isinstance(item, _PROHIBITED_EXPRESSION_NODES) for item in walk_ast(node))
598
653
 
599
654
 
600
- def _target_binding_is_observable(owner: _Callable, names: frozenset[str], loop: ast.For) -> bool:
601
- if _declares_external(owner, names) or any(_loads_name(loop.iter, name) for name in names):
655
+ def _owner_bindings(owner: _Callable, namespace_observers: frozenset[int]) -> _OwnerBindings:
656
+ nodes = tuple(walk_ast(owner))
657
+ names: defaultdict[str, list[ast.Name | ast.arg]] = defaultdict(list)
658
+ for node in nodes:
659
+ if isinstance(node, ast.Name):
660
+ names[node.id].append(node)
661
+ elif isinstance(node, ast.arg):
662
+ names[node.arg].append(node)
663
+ return _OwnerBindings(
664
+ names=dict(names),
665
+ external=frozenset(
666
+ name for node in nodes if isinstance(node, (ast.Global, ast.Nonlocal)) for name in node.names
667
+ ),
668
+ local_nodes=frozenset(_comprehension_local_nodes(nodes, frozenset(names))),
669
+ captured=_captured_names(owner),
670
+ observes_namespace=any(id(node) in namespace_observers for node in nodes),
671
+ )
672
+
673
+
674
+ def _namespace_observer_calls(context: PythonFileContext) -> frozenset[int]:
675
+ observers = {"locals", "vars", "eval", "exec"}
676
+ # Explicit aliases remain conservative exclusions even when another scope shadows them.
677
+ modules: set[str] = set()
678
+ symbols = observers.copy()
679
+ for node in context.nodes(ast.Import):
680
+ for alias in node.names:
681
+ if alias.name == "builtins":
682
+ modules.add(alias.asname or alias.name)
683
+ for node in context.nodes(ast.ImportFrom):
684
+ if node.module != "builtins":
685
+ continue
686
+ symbols.update(alias.asname or alias.name for alias in node.names if alias.name in observers)
687
+ return frozenset(
688
+ id(call) for call in context.nodes(ast.Call) if _is_namespace_observer(call.func, symbols, modules)
689
+ )
690
+
691
+
692
+ def _is_namespace_observer(function: ast.expr, symbols: set[str], modules: set[str]) -> bool:
693
+ match function:
694
+ case ast.Name(id=name):
695
+ return name in symbols
696
+ case ast.Attribute(value=ast.Name(id=module), attr=symbol):
697
+ return module in modules and symbol in {"locals", "vars", "eval", "exec"}
698
+ case _:
699
+ return False
700
+
701
+
702
+ def _captured_names(owner: _Callable) -> frozenset[str]:
703
+ captured: set[str] = set()
704
+ pending: list[tuple[ast.AST, bool]] = [(owner, False)]
705
+ while pending:
706
+ node, nested = pending.pop()
707
+ nested = nested or (node is not owner and isinstance(node, (_Callable, ast.Lambda, ast.ClassDef)))
708
+ if nested and isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load):
709
+ captured.add(node.id)
710
+ pending.extend((child, nested) for child in children(node))
711
+ return frozenset(captured)
712
+
713
+
714
+ def _target_binding_is_observable(bindings: _OwnerBindings, names: frozenset[str], loop: ast.For) -> bool:
715
+ if not bindings.external.isdisjoint(names) or any(_loads_name(loop.iter, name) for name in names):
602
716
  return True
603
717
  end_line = loop.end_lineno or loop.lineno
604
- comprehension_locals = _comprehension_local_nodes(owner, names)
605
- for node in walk_ast(owner):
606
- if isinstance(node, ast.arg) and node.arg in names:
607
- return True
608
- if id(node) in comprehension_locals:
609
- continue
610
- if (
611
- isinstance(node, ast.Name)
612
- and node.id in names
613
- and (
614
- getattr(node, "lineno", loop.lineno) < loop.lineno
615
- or (getattr(node, "lineno", 0) > end_line and isinstance(node.ctx, (ast.Load, ast.Del)))
616
- )
617
- ):
618
- return True
718
+ for name in names:
719
+ for node in bindings.names.get(name, []):
720
+ if isinstance(node, ast.arg):
721
+ return True
722
+ if id(node) in bindings.local_nodes:
723
+ continue
724
+ if node.lineno < loop.lineno or (node.lineno > end_line and isinstance(node.ctx, (ast.Load, ast.Del))):
725
+ return True
619
726
  return False
620
727
 
621
728
 
622
- def _comprehension_local_nodes(owner: _Callable, names: frozenset[str]) -> set[int]:
729
+ def _comprehension_local_nodes(nodes: Iterable[ast.AST], names: frozenset[str]) -> set[int]:
623
730
  local_nodes: set[int] = set()
624
- for node in walk_ast(owner):
731
+ for node in nodes:
625
732
  if not isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)):
626
733
  continue
627
- # Multiple generators can read an outer binding before a later generator shadows it.
628
- if len(node.generators) != 1:
629
- continue
734
+ # Only first-generator bindings are local before every later generator expression.
630
735
  bound = _bound_names(node.generators[0].target) & names
631
736
  if not bound:
632
737
  continue
633
738
  # The first iterable runs in the surrounding scope; targets and remaining expressions are local.
634
- scoped: list[ast.AST] = [node.generators[0].target, *node.generators[0].ifs]
739
+ scoped: list[ast.AST] = [node.generators[0].target, *node.generators[0].ifs, *node.generators[1:]]
635
740
  scoped.extend((node.key, node.value) if isinstance(node, ast.DictComp) else (node.elt,))
636
741
  local_nodes.update(
637
742
  id(child)
@@ -642,12 +747,6 @@ def _comprehension_local_nodes(owner: _Callable, names: frozenset[str]) -> set[i
642
747
  return local_nodes
643
748
 
644
749
 
645
- def _declares_external(owner: _Callable, names: frozenset[str]) -> bool:
646
- return any(
647
- isinstance(node, (ast.Global, ast.Nonlocal)) and not names.isdisjoint(node.names) for node in walk_ast(owner)
648
- )
649
-
650
-
651
750
  def _set_is_shadowed(tree: ast.Module, *, node_index: NodeIndex | None = None) -> bool:
652
751
  for node in walk_ast(tree, index=node_index):
653
752
  match node:
@@ -673,6 +772,7 @@ def _set_is_shadowed(tree: ast.Module, *, node_index: NodeIndex | None = None) -
673
772
 
674
773
 
675
774
  def _replacement_fits(init: _Init, name: str, expression: str, *, allow_multiline: bool = False) -> bool:
775
+ comprehension = ast.parse(expression, mode="eval").body
676
776
  prefix = f"{name} = "
677
777
  if isinstance(init, ast.AnnAssign):
678
778
  prefix = f"{name}: {ast.unparse(init.annotation)} = "
@@ -681,7 +781,6 @@ def _replacement_fits(init: _Init, name: str, expression: str, *, allow_multilin
681
781
  if not allow_multiline:
682
782
  return False
683
783
  if expression.startswith("{"):
684
- comprehension = ast.parse(expression, mode="eval").body
685
784
  if not isinstance(comprehension, ast.DictComp) or not isinstance(comprehension.value, ast.Call):
686
785
  return False
687
786
  call = comprehension.value
@@ -690,7 +789,8 @@ def _replacement_fits(init: _Init, name: str, expression: str, *, allow_multilin
690
789
  f"{ast.unparse(comprehension.key)}: {ast.unparse(call.func)}(",
691
790
  *(f" {ast.unparse(argument)}," for argument in call.args),
692
791
  *(f" {keyword.arg}={ast.unparse(keyword.value)}," for keyword in call.keywords),
693
- f"for {ast.unparse(generator.target)} in {ast.unparse(generator.iter)}",
792
+ f"for {ast.unparse(generator.target)} in {_comprehension_expression_source(generator.iter)}",
793
+ *(f"if {_comprehension_expression_source(test)}" for test in generator.ifs),
694
794
  )
695
795
  return init.col_offset + len(prefix) + 1 <= _MAX_REPLACEMENT_WIDTH and all(
696
796
  init.col_offset + 4 + len(line) <= _MAX_REPLACEMENT_WIDTH for line in lines
@@ -718,24 +818,24 @@ def _has_comment(init: _Init, loop: ast.For, comments: frozenset[int]) -> bool:
718
818
 
719
819
 
720
820
  def _set_replacement(loop: ast.For, name: str, bound_names: frozenset[str]) -> _LoopReplacement | None:
721
- match loop.body[0]:
722
- case ast.If(test=test, body=[body], orelse=[]):
723
- expression = _single_method_argument(body, name, "add")
724
- if (
725
- expression is not None
726
- and not _loads_name(test, name)
727
- and not _loads_name(expression, name)
728
- and _simple_projection(expression, bound_names)
729
- and not _contains_prohibited_expression(test)
730
- ):
731
- return _LoopReplacement(
732
- _CollectionKind.SET,
733
- f"{{{ast.unparse(expression)} for {ast.unparse(loop.target)} in {ast.unparse(loop.iter)} "
734
- f"if {ast.unparse(test)}}}",
735
- )
736
- case _:
737
- pass
738
- return None
821
+ parts = _filtered_statement_parts(loop.body)
822
+ if parts is None:
823
+ return None
824
+ statement, test, inverted = parts
825
+ expression = _single_method_argument(statement, name, "add")
826
+ if (
827
+ expression is None
828
+ or _loads_name(test, name)
829
+ or _loads_name(expression, name)
830
+ or not _simple_projection(expression, bound_names)
831
+ or _contains_prohibited_expression(test)
832
+ ):
833
+ return None
834
+ return _LoopReplacement(
835
+ _CollectionKind.SET,
836
+ f"{{{ast.unparse(expression)} for {ast.unparse(loop.target)} "
837
+ f"in {_comprehension_expression_source(loop.iter)} if {_condition_source(test, inverted=inverted)}}}",
838
+ )
739
839
 
740
840
 
741
841
  def _message(finding: _Candidate) -> str: