sarj-python-lint 1.24.0__tar.gz → 1.25.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 (182) hide show
  1. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/PKG-INFO +1 -1
  2. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/pyproject.toml +1 -1
  3. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +227 -122
  4. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/.gitignore +0 -0
  5. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/LICENSE +0 -0
  6. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/README.md +0 -0
  7. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/__init__.py +0 -0
  8. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/__main__.py +0 -0
  9. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/_analysis_session.py +0 -0
  10. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/_file_context.py +0 -0
  11. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/_filesystem.py +0 -0
  12. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/_python_target.py +0 -0
  13. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  14. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/_secret_names.py +0 -0
  15. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/_version.py +0 -0
  16. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/benchmark.py +0 -0
  17. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/json_boundary.py +0 -0
  18. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/py.typed +0 -0
  19. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/ratchet.py +0 -0
  20. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rule_base.py +0 -0
  21. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/__init__.py +0 -0
  22. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
  23. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  24. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_ast_position.py +0 -0
  25. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_comments.py +0 -0
  26. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_contract_annotation.py +0 -0
  27. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_copy_on_write.py +0 -0
  28. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_dict_key_views.py +0 -0
  29. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  30. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  31. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_first_party.py +0 -0
  32. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  33. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_imports.py +0 -0
  34. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_local_source.py +0 -0
  35. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_logging.py +0 -0
  36. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_nominal_project.py +0 -0
  37. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_paths.py +0 -0
  38. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_project_index.py +0 -0
  39. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  40. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_pytest.py +0 -0
  41. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_registry.py +0 -0
  42. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_resource_provenance.py +0 -0
  43. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_set_fields.py +0 -0
  44. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_sql.py +0 -0
  45. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  46. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  47. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_test_composition.py +0 -0
  48. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/_test_provenance.py +0 -0
  49. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/async_cleanup_registered_synchronously.py +0 -0
  50. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/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.1}/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.1}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  54. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  55. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  56. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  57. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
  58. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  59. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  60. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  61. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  62. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
  63. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/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.1}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
  66. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/src/sarj_python_lint/rules/no_broad_keyword_capture.py +0 -0
  68. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  69. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  71. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/src/sarj_python_lint/rules/no_database_programmability.py +0 -0
  73. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
  74. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  75. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  77. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  78. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/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.1}/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.1}/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.1}/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.1}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  84. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/no_injected_module_loader.py +0 -0
  85. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/no_input_model_mutation.py +0 -0
  86. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/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.1}/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.1}/src/sarj_python_lint/rules/no_nullable_dependency_fallback.py +0 -0
  90. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  91. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/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.1}/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.1}/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.1}/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.1}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  97. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  99. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  100. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  101. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  102. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  103. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/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.1}/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.1}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
  107. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  108. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/no_test_method_grafting.py +0 -0
  109. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  110. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  112. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/no_unused_copy_result.py +0 -0
  113. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
  115. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  116. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/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.1}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  119. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  120. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  122. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  124. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  125. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  127. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  128. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
  129. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  130. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  131. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  132. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/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.1}/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.1}/src/sarj_python_lint/rules/prefer_native_string_check.py +0 -0
  136. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  137. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  138. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  139. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
  140. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/prefer_pytest_fixture_injection.py +0 -0
  141. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/prefer_regex_fullmatch.py +0 -0
  142. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/prefer_required_constructor_parameters.py +0 -0
  143. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  144. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  145. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  146. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  147. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  148. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  150. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  151. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  152. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  153. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  155. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  156. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  157. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  159. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
  160. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/require_explicit_contract_implementation.py +0 -0
  161. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/require_explicit_psycopg_transaction.py +0 -0
  162. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/require_explicit_service_contract.py +0 -0
  163. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/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.1}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  166. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/require_precise_factory_signature.py +0 -0
  167. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/require_public_dependency_contract.py +0 -0
  168. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/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.1}/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.1}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  172. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/shared_mutable_pydantic_factory.py +0 -0
  173. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/stepdown.py +0 -0
  174. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.1}/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.1}/src/sarj_python_lint/rules/subprocess_kill_without_reap.py +0 -0
  177. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  178. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  179. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  180. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  181. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  182. {sarj_python_lint-1.24.0 → sarj_python_lint-1.25.1}/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.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.24.0"
3
+ version = "1.25.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" }]
@@ -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,14 @@ 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, explicit inspect/sys frame access, and collections referenced by nested functions "
116
+ "are excluded. No autofix is offered because dict key/value evaluation order can differ."
106
117
  ),
107
118
  (
108
119
  "Attribute projections can invoke properties or descriptors, so reviewers should keep the loop "
@@ -241,32 +252,51 @@ class PreferCollectionComprehension(Rule):
241
252
  source_lines = context.source_lines
242
253
  comments = _comment_lines(source)
243
254
  diagnostics: list[Diagnostic] = []
255
+ set_shadowed = _set_is_shadowed(tree, node_index=context.node_index)
256
+ namespace_observers = _namespace_observer_calls(context)
244
257
  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
- )
258
+ for finding, loop in _owner_candidates(
259
+ owner, source_lines, comments, namespace_observers, set_shadowed=set_shadowed
260
+ ):
261
+ diagnostics.append(
262
+ Diagnostic(
263
+ path=path,
264
+ line=loop.lineno,
265
+ col=loop.col_offset + 1,
266
+ code=self.code,
267
+ message=_message(finding),
266
268
  )
269
+ )
267
270
  return sorted(diagnostics, key=lambda diagnostic: (diagnostic.line, diagnostic.col))
268
271
 
269
272
 
273
+ def _owner_candidates(
274
+ owner: _Callable,
275
+ source_lines: list[str],
276
+ comments: frozenset[int],
277
+ namespace_observers: frozenset[int],
278
+ *,
279
+ set_shadowed: bool,
280
+ ) -> Iterator[tuple[_Candidate, ast.For]]:
281
+ bindings = None
282
+ for block in _statement_blocks(owner.body):
283
+ for init, loop in pairwise(block):
284
+ if not isinstance(init, _Init) or not isinstance(loop, ast.For) or _initialized_collection(init) is None:
285
+ continue
286
+ if bindings is None:
287
+ bindings = _owner_bindings(owner, namespace_observers)
288
+ finding = _candidate(
289
+ bindings,
290
+ init=init,
291
+ loop=loop,
292
+ source_lines=source_lines,
293
+ comments=comments,
294
+ set_shadowed=set_shadowed,
295
+ )
296
+ if finding is not None:
297
+ yield finding, loop
298
+
299
+
270
300
  def _statement_blocks(body: list[ast.stmt]) -> Iterator[list[ast.stmt]]:
271
301
  yield body
272
302
  for statement in body:
@@ -286,14 +316,13 @@ def _statement_blocks(body: list[ast.stmt]) -> Iterator[list[ast.stmt]]:
286
316
 
287
317
 
288
318
  def _candidate(
289
- tree: ast.Module,
290
- owner: _Callable,
319
+ bindings: _OwnerBindings,
291
320
  *,
292
321
  init: ast.stmt,
293
322
  loop: ast.stmt,
294
323
  source_lines: list[str],
295
324
  comments: frozenset[int],
296
- node_index: NodeIndex | None = None,
325
+ set_shadowed: bool,
297
326
  ) -> _Candidate | None:
298
327
  if not isinstance(init, _Init) or not isinstance(loop, ast.For) or loop.orelse:
299
328
  return None
@@ -301,7 +330,9 @@ def _candidate(
301
330
  if initialized is None:
302
331
  return None
303
332
  name = initialized.name
304
- if initialized.kind is _CollectionKind.SET and _set_is_shadowed(tree, node_index=node_index):
333
+ if bindings.observes_namespace or name in bindings.captured:
334
+ return None
335
+ if initialized.kind is _CollectionKind.SET and set_shadowed:
305
336
  return None
306
337
  if _has_comment(init, loop, comments) or is_suppressed(
307
338
  source_lines, loop.lineno, PreferCollectionComprehension.code
@@ -310,19 +341,19 @@ def _candidate(
310
341
  bound_names = _bound_names(loop.target)
311
342
  if not bound_names or _contains_starred(loop.target) or name in bound_names:
312
343
  return None
313
- if _target_binding_is_observable(owner, bound_names, loop):
344
+ if _target_binding_is_observable(bindings, bound_names, loop):
314
345
  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
- ):
346
+ if _loads_name(loop.iter, name) or _contains_prohibited_expression(loop.iter) or name in bindings.external:
320
347
  return None
321
348
 
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
- ):
349
+ try:
350
+ replacement = _loop_replacement(loop, name, initialized.kind, bound_names, bindings)
351
+ fits = replacement is not None and _replacement_fits(
352
+ init, name, replacement.expression, allow_multiline=replacement.allow_multiline
353
+ )
354
+ except RecursionError, SyntaxError:
355
+ return None
356
+ if replacement is None or not fits:
326
357
  return None
327
358
  return _Candidate(kind=replacement.kind, name=name, action=replacement.action)
328
359
 
@@ -354,40 +385,56 @@ def _loop_replacement(
354
385
  name: str,
355
386
  initialized_kind: _CollectionKind,
356
387
  bound_names: frozenset[str],
357
- owner: _Callable,
388
+ bindings: _OwnerBindings,
358
389
  ) -> _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
390
+ if initialized_kind is _CollectionKind.DICT:
391
+ return _dict_replacement(loop, name, bound_names)
373
392
  if initialized_kind is _CollectionKind.LIST:
374
- replacement = _list_replacement(loop, name, bound_names, owner)
393
+ replacement = _list_replacement(loop, name, bound_names, bindings)
375
394
  if replacement is not None:
376
395
  return replacement
377
- if initialized_kind is _CollectionKind.SET and len(loop.body) == 1:
396
+ if initialized_kind is _CollectionKind.SET:
378
397
  return _set_replacement(loop, name, bound_names)
379
398
  return None
380
399
 
381
400
 
401
+ def _dict_replacement(loop: ast.For, name: str, bound_names: frozenset[str]) -> _LoopReplacement | None:
402
+ condition = ""
403
+ filtered = _filtered_statement_parts(loop.body)
404
+ if filtered is not None:
405
+ statement, test, inverted = filtered
406
+ if _loads_name(test, name) or _contains_prohibited_expression(test):
407
+ return None
408
+ condition = f" if {_condition_source(test, inverted=inverted)}"
409
+ elif len(loop.body) == 1:
410
+ statement = loop.body[0]
411
+ else:
412
+ return None
413
+ match statement:
414
+ case ast.Assign(targets=[ast.Subscript(value=ast.Name(id=target), slice=key)], value=value) if target == name:
415
+ pass
416
+ case _:
417
+ return None
418
+ if not _valid_dict_projection(key, value, name, bound_names):
419
+ return None
420
+ return _LoopReplacement(
421
+ _CollectionKind.DICT,
422
+ f"{{{ast.unparse(key)}: {ast.unparse(value)} for {ast.unparse(loop.target)} "
423
+ f"in {_comprehension_expression_source(loop.iter)}{condition}}}",
424
+ "filters and populates" if filtered is not None else "populates",
425
+ allow_multiline=isinstance(value, ast.Call),
426
+ )
427
+
428
+
382
429
  def _list_replacement(
383
- loop: ast.For, name: str, bound_names: frozenset[str], owner: _Callable
430
+ loop: ast.For, name: str, bound_names: frozenset[str], bindings: _OwnerBindings
384
431
  ) -> _LoopReplacement | None:
385
432
  if len(bound_names) > 1 and len(loop.body) == 1:
386
433
  expression = _single_method_argument(loop.body[0], name, "append")
387
434
  if expression is not None and not _loads_name(expression, name) and _simple_projection(expression, bound_names):
388
435
  return _LoopReplacement(
389
436
  _CollectionKind.LIST,
390
- f"[{ast.unparse(expression)} for {ast.unparse(loop.target)} in {ast.unparse(loop.iter)}]",
437
+ f"[{ast.unparse(expression)} for {ast.unparse(loop.target)} in {_comprehension_expression_source(loop.iter)}]",
391
438
  )
392
439
 
393
440
  filtered = _filtered_list_parts(loop.body, name)
@@ -402,32 +449,36 @@ def _list_replacement(
402
449
  condition = _condition_source(test, inverted=inverted)
403
450
  return _LoopReplacement(
404
451
  _CollectionKind.LIST,
405
- f"[{ast.unparse(expression)} for {ast.unparse(loop.target)} in {ast.unparse(loop.iter)} "
452
+ f"[{ast.unparse(expression)} for {ast.unparse(loop.target)} in {_comprehension_expression_source(loop.iter)} "
406
453
  f"if {condition}]",
407
454
  "filters and appends to",
408
455
  allow_multiline=True,
409
456
  )
410
457
 
411
- return _computed_list_replacement(loop, name, bound_names, owner)
458
+ return _computed_list_replacement(loop, name, bound_names, bindings)
412
459
 
413
460
 
414
- def _filtered_list_parts(body: list[ast.stmt], name: str) -> tuple[ast.expr, ast.expr, bool] | None:
461
+ def _filtered_statement_parts(body: list[ast.stmt]) -> tuple[ast.stmt, ast.expr, bool] | None:
415
462
  match body:
416
463
  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
464
+ return statement, test, False
420
465
  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
466
+ return statement, test, True
424
467
  case _:
425
- pass
426
- return None
468
+ return None
469
+
470
+
471
+ def _filtered_list_parts(body: list[ast.stmt], name: str) -> tuple[ast.expr, ast.expr, bool] | None:
472
+ parts = _filtered_statement_parts(body)
473
+ if parts is None:
474
+ return None
475
+ statement, test, inverted = parts
476
+ expression = _single_method_argument(statement, name, "append")
477
+ return (expression, test, inverted) if expression is not None else None
427
478
 
428
479
 
429
480
  def _computed_list_replacement(
430
- loop: ast.For, name: str, bound_names: frozenset[str], owner: _Callable
481
+ loop: ast.For, name: str, bound_names: frozenset[str], bindings: _OwnerBindings
431
482
  ) -> _LoopReplacement | None:
432
483
  match loop.body:
433
484
  case [ast.Assign(targets=[ast.Name(id=candidate)], value=value), *tail]:
@@ -437,7 +488,7 @@ def _computed_list_replacement(
437
488
 
438
489
  if candidate == name or candidate in bound_names:
439
490
  return None
440
- if _target_binding_is_observable(owner, frozenset({candidate}), loop):
491
+ if _target_binding_is_observable(bindings, frozenset({candidate}), loop):
441
492
  return None
442
493
  if _loads_name(value, name) or _contains_prohibited_expression(value):
443
494
  return None
@@ -459,7 +510,7 @@ def _computed_list_replacement(
459
510
  condition = assignment if condition_kind == "truthy" else f"{assignment} is not None"
460
511
  return _LoopReplacement(
461
512
  _CollectionKind.LIST,
462
- f"[{ast.unparse(expression)} for {ast.unparse(loop.target)} in {ast.unparse(loop.iter)} if {condition}]",
513
+ f"[{ast.unparse(expression)} for {ast.unparse(loop.target)} in {_comprehension_expression_source(loop.iter)} if {condition}]",
463
514
  "computes, filters, and appends to",
464
515
  allow_multiline=True,
465
516
  )
@@ -517,9 +568,14 @@ def _dotted_name(node: ast.expr) -> bool:
517
568
  return False
518
569
 
519
570
 
571
+ def _comprehension_expression_source(iterable: ast.expr) -> str:
572
+ source = ast.unparse(iterable)
573
+ return f"({source})" if isinstance(iterable, ast.IfExp) else source
574
+
575
+
520
576
  def _condition_source(test: ast.expr, *, inverted: bool) -> str:
521
577
  if not inverted:
522
- return ast.unparse(test)
578
+ return _comprehension_expression_source(test)
523
579
  return ast.unparse(ast.UnaryOp(op=ast.Not(), operand=test))
524
580
 
525
581
 
@@ -597,41 +653,95 @@ def _contains_prohibited_expression(node: ast.AST) -> bool:
597
653
  return any(isinstance(item, _PROHIBITED_EXPRESSION_NODES) for item in walk_ast(node))
598
654
 
599
655
 
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):
656
+ def _owner_bindings(owner: _Callable, namespace_observers: frozenset[int]) -> _OwnerBindings:
657
+ nodes = tuple(walk_ast(owner))
658
+ names: defaultdict[str, list[ast.Name | ast.arg]] = defaultdict(list)
659
+ for node in nodes:
660
+ if isinstance(node, ast.Name):
661
+ names[node.id].append(node)
662
+ elif isinstance(node, ast.arg):
663
+ names[node.arg].append(node)
664
+ return _OwnerBindings(
665
+ names=dict(names),
666
+ external=frozenset(
667
+ name for node in nodes if isinstance(node, (ast.Global, ast.Nonlocal)) for name in node.names
668
+ ),
669
+ local_nodes=frozenset(_comprehension_local_nodes(nodes, frozenset(names))),
670
+ captured=_captured_names(owner),
671
+ observes_namespace=any(id(node) in namespace_observers for node in nodes),
672
+ )
673
+
674
+
675
+ def _namespace_observer_calls(context: PythonFileContext) -> frozenset[int]:
676
+ observers = {
677
+ "builtins": {"locals", "vars", "eval", "exec"},
678
+ "inspect": {"currentframe"},
679
+ "sys": {"_getframe"},
680
+ }
681
+ # Explicit aliases remain conservative exclusions even when another scope shadows them.
682
+ modules: defaultdict[str, set[str]] = defaultdict(set)
683
+ symbols = observers["builtins"].copy()
684
+ for node in context.nodes(ast.Import):
685
+ for alias in node.names:
686
+ if alias.name in observers:
687
+ modules[alias.asname or alias.name].update(observers[alias.name])
688
+ for node in context.nodes(ast.ImportFrom):
689
+ if node.module not in observers:
690
+ continue
691
+ symbols.update(alias.asname or alias.name for alias in node.names if alias.name in observers[node.module])
692
+ return frozenset(
693
+ id(call) for call in context.nodes(ast.Call) if _is_namespace_observer(call.func, symbols, modules)
694
+ )
695
+
696
+
697
+ def _is_namespace_observer(function: ast.expr, symbols: set[str], modules: dict[str, set[str]]) -> bool:
698
+ match function:
699
+ case ast.Name(id=name):
700
+ return name in symbols
701
+ case ast.Attribute(value=ast.Name(id=module), attr=symbol):
702
+ return module in modules and symbol in modules[module]
703
+ case _:
704
+ return False
705
+
706
+
707
+ def _captured_names(owner: _Callable) -> frozenset[str]:
708
+ captured: set[str] = set()
709
+ pending: list[tuple[ast.AST, bool]] = [(owner, False)]
710
+ while pending:
711
+ node, nested = pending.pop()
712
+ nested = nested or (node is not owner and isinstance(node, (_Callable, ast.Lambda, ast.ClassDef)))
713
+ if nested and isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load):
714
+ captured.add(node.id)
715
+ pending.extend((child, nested) for child in children(node))
716
+ return frozenset(captured)
717
+
718
+
719
+ def _target_binding_is_observable(bindings: _OwnerBindings, names: frozenset[str], loop: ast.For) -> bool:
720
+ if not bindings.external.isdisjoint(names) or any(_loads_name(loop.iter, name) for name in names):
602
721
  return True
603
722
  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
723
+ for name in names:
724
+ for node in bindings.names.get(name, []):
725
+ if isinstance(node, ast.arg):
726
+ return True
727
+ if id(node) in bindings.local_nodes:
728
+ continue
729
+ if node.lineno < loop.lineno or (node.lineno > end_line and isinstance(node.ctx, (ast.Load, ast.Del))):
730
+ return True
619
731
  return False
620
732
 
621
733
 
622
- def _comprehension_local_nodes(owner: _Callable, names: frozenset[str]) -> set[int]:
734
+ def _comprehension_local_nodes(nodes: Iterable[ast.AST], names: frozenset[str]) -> set[int]:
623
735
  local_nodes: set[int] = set()
624
- for node in walk_ast(owner):
736
+ for node in nodes:
625
737
  if not isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)):
626
738
  continue
627
- # Multiple generators can read an outer binding before a later generator shadows it.
628
- if len(node.generators) != 1:
629
- continue
739
+ # Only first-generator bindings are local before every later generator expression.
630
740
  bound = _bound_names(node.generators[0].target) & names
631
741
  if not bound:
632
742
  continue
633
743
  # 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]
744
+ scoped: list[ast.AST] = [node.generators[0].target, *node.generators[0].ifs, *node.generators[1:]]
635
745
  scoped.extend((node.key, node.value) if isinstance(node, ast.DictComp) else (node.elt,))
636
746
  local_nodes.update(
637
747
  id(child)
@@ -642,12 +752,6 @@ def _comprehension_local_nodes(owner: _Callable, names: frozenset[str]) -> set[i
642
752
  return local_nodes
643
753
 
644
754
 
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
755
  def _set_is_shadowed(tree: ast.Module, *, node_index: NodeIndex | None = None) -> bool:
652
756
  for node in walk_ast(tree, index=node_index):
653
757
  match node:
@@ -673,6 +777,7 @@ def _set_is_shadowed(tree: ast.Module, *, node_index: NodeIndex | None = None) -
673
777
 
674
778
 
675
779
  def _replacement_fits(init: _Init, name: str, expression: str, *, allow_multiline: bool = False) -> bool:
780
+ comprehension = ast.parse(expression, mode="eval").body
676
781
  prefix = f"{name} = "
677
782
  if isinstance(init, ast.AnnAssign):
678
783
  prefix = f"{name}: {ast.unparse(init.annotation)} = "
@@ -681,7 +786,6 @@ def _replacement_fits(init: _Init, name: str, expression: str, *, allow_multilin
681
786
  if not allow_multiline:
682
787
  return False
683
788
  if expression.startswith("{"):
684
- comprehension = ast.parse(expression, mode="eval").body
685
789
  if not isinstance(comprehension, ast.DictComp) or not isinstance(comprehension.value, ast.Call):
686
790
  return False
687
791
  call = comprehension.value
@@ -690,7 +794,8 @@ def _replacement_fits(init: _Init, name: str, expression: str, *, allow_multilin
690
794
  f"{ast.unparse(comprehension.key)}: {ast.unparse(call.func)}(",
691
795
  *(f" {ast.unparse(argument)}," for argument in call.args),
692
796
  *(f" {keyword.arg}={ast.unparse(keyword.value)}," for keyword in call.keywords),
693
- f"for {ast.unparse(generator.target)} in {ast.unparse(generator.iter)}",
797
+ f"for {ast.unparse(generator.target)} in {_comprehension_expression_source(generator.iter)}",
798
+ *(f"if {_comprehension_expression_source(test)}" for test in generator.ifs),
694
799
  )
695
800
  return init.col_offset + len(prefix) + 1 <= _MAX_REPLACEMENT_WIDTH and all(
696
801
  init.col_offset + 4 + len(line) <= _MAX_REPLACEMENT_WIDTH for line in lines
@@ -718,24 +823,24 @@ def _has_comment(init: _Init, loop: ast.For, comments: frozenset[int]) -> bool:
718
823
 
719
824
 
720
825
  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
826
+ parts = _filtered_statement_parts(loop.body)
827
+ if parts is None:
828
+ return None
829
+ statement, test, inverted = parts
830
+ expression = _single_method_argument(statement, name, "add")
831
+ if (
832
+ expression is None
833
+ or _loads_name(test, name)
834
+ or _loads_name(expression, name)
835
+ or not _simple_projection(expression, bound_names)
836
+ or _contains_prohibited_expression(test)
837
+ ):
838
+ return None
839
+ return _LoopReplacement(
840
+ _CollectionKind.SET,
841
+ f"{{{ast.unparse(expression)} for {ast.unparse(loop.target)} "
842
+ f"in {_comprehension_expression_source(loop.iter)} if {_condition_source(test, inverted=inverted)}}}",
843
+ )
739
844
 
740
845
 
741
846
  def _message(finding: _Candidate) -> str: