sarj-python-lint 0.82.6__tar.gz → 0.82.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +253 -51
  4. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/.gitignore +0 -0
  5. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/LICENSE +0 -0
  6. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/README.md +0 -0
  7. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/__init__.py +0 -0
  8. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/__main__.py +0 -0
  9. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/_analysis_session.py +0 -0
  10. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/_filesystem.py +0 -0
  11. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/_python_target.py +0 -0
  12. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  13. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/_secret_names.py +0 -0
  14. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/_version.py +0 -0
  15. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/py.typed +0 -0
  16. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/ratchet.py +0 -0
  17. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rule_base.py +0 -0
  18. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/__init__.py +0 -0
  19. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  20. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/_comments.py +0 -0
  21. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  22. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  23. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/_first_party.py +0 -0
  24. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  25. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/_imports.py +0 -0
  26. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/_logging.py +0 -0
  27. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/_paths.py +0 -0
  28. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/_project_index.py +0 -0
  29. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  30. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/_pytest.py +0 -0
  31. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/_registry.py +0 -0
  32. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/_sql.py +0 -0
  33. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  34. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  35. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  36. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  37. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  38. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  39. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  40. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  41. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  42. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  43. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  44. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  45. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  46. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  47. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  48. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  49. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  50. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  51. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_deleted_only_override_parameter.py +0 -0
  52. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  53. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  54. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  55. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  56. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  57. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  58. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  59. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  60. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  61. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  62. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  63. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  64. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  65. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  66. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  67. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  68. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  69. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  70. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  71. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  72. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  73. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  74. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  75. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  76. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  77. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  78. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  79. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  80. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  81. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  82. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  83. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  84. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  85. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  86. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  87. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  88. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  89. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  90. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
  91. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  92. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  93. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  94. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  95. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  96. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
  97. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  98. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  99. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  100. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  101. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  102. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  103. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  104. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  105. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  106. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  107. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  108. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  109. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  110. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  111. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  112. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  113. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  114. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  115. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  116. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  117. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  118. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  119. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  120. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  121. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  122. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  123. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  124. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/stepdown.py +0 -0
  125. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  126. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  127. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  128. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  129. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  130. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  131. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  132. {sarj_python_lint-0.82.6 → sarj_python_lint-0.82.7}/src/sarj_python_lint/rules/unused_test_factory_option.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sarj-python-lint
3
- Version: 0.82.6
3
+ Version: 0.82.7
4
4
  Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
5
  Project-URL: Homepage, https://code-standards.sarj.ai/rules/python/
6
6
  Project-URL: Documentation, https://code-standards.sarj.ai/rules/python/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.82.6"
3
+ version = "0.82.7"
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" }]
@@ -61,6 +61,15 @@ class _InitializedCollection:
61
61
  class _Candidate:
62
62
  kind: _CollectionKind
63
63
  name: str
64
+ action: str = "populates"
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class _LoopReplacement:
69
+ kind: _CollectionKind
70
+ expression: str
71
+ action: str = "populates"
72
+ allow_multiline: bool = False
64
73
 
65
74
 
66
75
  @final
@@ -74,8 +83,9 @@ class PreferCollectionComprehension(Rule):
74
83
  "spreads a declarative map/filter across mutable scaffolding."
75
84
  ),
76
85
  remediation=(
77
- "Build the fresh collection with one dict, list, or set comprehension. Keep the loop when mutation is "
78
- "incremental, evaluation order is observable, or the imperative form carries additional behavior."
86
+ "Build the fresh collection with one dict, list, or set comprehension. When a list candidate must be "
87
+ "computed once and reused in the element, bind it in the filter with :=. Keep the loop when mutation "
88
+ "is incremental, evaluation order is observable, or the imperative form carries additional behavior."
79
89
  ),
80
90
  category=RuleCategory.STYLE,
81
91
  autofix=AutofixPolicy.NONE,
@@ -83,17 +93,23 @@ class PreferCollectionComprehension(Rule):
83
93
  "Only function-local, adjacent empty initializers and one synchronous single-purpose loop are checked.",
84
94
  (
85
95
  "The rule fills gaps left by Ruff PERF401, PERF403, and FURB142: derived dict projections, "
86
- "destructured list projections, and filtered set builders."
96
+ "destructured list projections, narrowly filtered computed list candidates, and filtered set "
97
+ "builders."
87
98
  ),
88
99
  (
89
- "Comments, loop-target leakage, try blocks, aliases, complex projections, and replacements wider "
90
- "than 120 columns are excluded. No autofix is offered because dict key/value evaluation order can "
91
- "differ."
100
+ "Comments, loop-target leakage, try blocks, aliases, complex projections, and replacements that "
101
+ "do not fit a 120-column line or a simple formatter-style multiline comprehension are excluded. "
102
+ "No autofix is offered because dict key/value evaluation order can differ."
92
103
  ),
93
104
  (
94
105
  "Attribute projections can invoke properties or descriptors, so reviewers should keep the loop "
95
106
  "when the relative order of those reads is observable."
96
107
  ),
108
+ (
109
+ "Computed list candidates are limited to one local assignment, a truthiness or None filter, and "
110
+ "one bounded projection. Validation, logging, exceptions, multiple guards, and secondary mutation "
111
+ "remain imperative."
112
+ ),
97
113
  ),
98
114
  examples=(
99
115
  RuleExample(
@@ -114,6 +130,47 @@ class PreferCollectionComprehension(Rule):
114
130
  expected_count=1,
115
131
  public=True,
116
132
  ),
133
+ RuleExample(
134
+ example_id="computed-filtered-list-loop",
135
+ title="Compute and filter a list candidate directly",
136
+ outcome=ExampleOutcome.MATCH,
137
+ files=(
138
+ ExampleFile.python(
139
+ "app/intervals.py",
140
+ "def clipped(items, bounds):\n"
141
+ " result: list[Interval] = []\n"
142
+ " for item in items:\n"
143
+ " value = item.clipped(bounds)\n"
144
+ " if value is not None:\n"
145
+ " result.append(value)\n"
146
+ " return result\n",
147
+ ),
148
+ ),
149
+ focus_path=PurePosixPath("app/intervals.py"),
150
+ expected_count=1,
151
+ public=True,
152
+ scenario="computed-filter",
153
+ ),
154
+ RuleExample(
155
+ example_id="computed-filtered-list-comprehension",
156
+ title="Keep a computed filtered list declarative",
157
+ outcome=ExampleOutcome.NO_MATCH,
158
+ files=(
159
+ ExampleFile.python(
160
+ "app/intervals.py",
161
+ "def clipped(items, bounds):\n"
162
+ " return [\n"
163
+ " value\n"
164
+ " for item in items\n"
165
+ " if (value := item.clipped(bounds)) is not None\n"
166
+ " ]\n",
167
+ ),
168
+ ),
169
+ focus_path=PurePosixPath("app/intervals.py"),
170
+ expected_count=0,
171
+ public=True,
172
+ scenario="computed-filter",
173
+ ),
117
174
  RuleExample(
118
175
  example_id="direct-dict-comprehension",
119
176
  title="Keep the collection projection declarative",
@@ -163,10 +220,7 @@ class PreferCollectionComprehension(Rule):
163
220
  line=loop.lineno,
164
221
  col=loop.col_offset + 1,
165
222
  code=self.code,
166
- message=(
167
- f"This loop only populates fresh {finding.kind} {finding.name!r} — prefer a "
168
- f"{finding.kind} comprehension."
169
- ),
223
+ message=_message(finding),
170
224
  )
171
225
  )
172
226
  return sorted(diagnostics, key=lambda diagnostic: (diagnostic.line, diagnostic.col))
@@ -223,10 +277,12 @@ def _candidate(
223
277
  ):
224
278
  return None
225
279
 
226
- kind = _loop_kind(loop, name, initialized.kind, bound_names)
227
- if kind is None or not _replacement_fits(init, loop, name, kind):
280
+ replacement = _loop_replacement(loop, name, initialized.kind, bound_names, owner)
281
+ if replacement is None or not _replacement_fits(
282
+ init, name, replacement.expression, allow_multiline=replacement.allow_multiline
283
+ ):
228
284
  return None
229
- return _Candidate(kind=kind, name=name)
285
+ return _Candidate(kind=replacement.kind, name=name, action=replacement.action)
230
286
 
231
287
 
232
288
  def _initialized_collection(statement: _Init) -> _InitializedCollection | None:
@@ -251,27 +307,179 @@ def _initialized_collection(statement: _Init) -> _InitializedCollection | None:
251
307
  return None
252
308
 
253
309
 
254
- def _loop_kind(
255
- loop: ast.For, name: str, initialized_kind: _CollectionKind, bound_names: frozenset[str]
256
- ) -> _CollectionKind | None:
310
+ def _loop_replacement(
311
+ loop: ast.For,
312
+ name: str,
313
+ initialized_kind: _CollectionKind,
314
+ bound_names: frozenset[str],
315
+ owner: _Callable,
316
+ ) -> _LoopReplacement | None:
257
317
  if initialized_kind is _CollectionKind.DICT and len(loop.body) == 1:
258
318
  match loop.body[0]:
259
319
  case ast.Assign(targets=[ast.Subscript(value=ast.Name(id=target), slice=key)], value=value) if (
260
320
  target == name
261
321
  ):
262
322
  if _valid_dict_projection(key, value, name, bound_names):
263
- return _CollectionKind.DICT
323
+ return _LoopReplacement(
324
+ _CollectionKind.DICT,
325
+ f"{{{ast.unparse(key)}: {ast.unparse(value)} for {ast.unparse(loop.target)} "
326
+ f"in {ast.unparse(loop.iter)}}}",
327
+ )
264
328
  case _:
265
329
  pass
266
- if initialized_kind is _CollectionKind.LIST and len(bound_names) > 1 and len(loop.body) == 1:
330
+ if initialized_kind is _CollectionKind.LIST:
331
+ replacement = _list_replacement(loop, name, bound_names, owner)
332
+ if replacement is not None:
333
+ return replacement
334
+ if initialized_kind is _CollectionKind.SET and len(loop.body) == 1:
335
+ return _set_replacement(loop, name, bound_names)
336
+ return None
337
+
338
+
339
+ def _list_replacement(
340
+ loop: ast.For, name: str, bound_names: frozenset[str], owner: _Callable
341
+ ) -> _LoopReplacement | None:
342
+ if len(bound_names) > 1 and len(loop.body) == 1:
267
343
  expression = _single_method_argument(loop.body[0], name, "append")
268
344
  if expression is not None and not _loads_name(expression, name) and _simple_projection(expression, bound_names):
269
- return _CollectionKind.LIST
270
- if initialized_kind is _CollectionKind.SET and len(loop.body) == 1:
271
- return _set_loop_kind(loop, name, bound_names)
345
+ return _LoopReplacement(
346
+ _CollectionKind.LIST,
347
+ f"[{ast.unparse(expression)} for {ast.unparse(loop.target)} in {ast.unparse(loop.iter)}]",
348
+ )
349
+
350
+ filtered = _filtered_list_parts(loop.body, name)
351
+ if filtered is not None and len(bound_names) > 1:
352
+ expression, test, inverted = filtered
353
+ if (
354
+ not _loads_name(expression, name)
355
+ and not _loads_name(test, name)
356
+ and _simple_projection(expression, bound_names)
357
+ and not _contains_prohibited_expression(test)
358
+ ):
359
+ condition = _condition_source(test, inverted=inverted)
360
+ return _LoopReplacement(
361
+ _CollectionKind.LIST,
362
+ f"[{ast.unparse(expression)} for {ast.unparse(loop.target)} in {ast.unparse(loop.iter)} "
363
+ f"if {condition}]",
364
+ "filters and appends to",
365
+ allow_multiline=True,
366
+ )
367
+
368
+ return _computed_list_replacement(loop, name, bound_names, owner)
369
+
370
+
371
+ def _filtered_list_parts(body: list[ast.stmt], name: str) -> tuple[ast.expr, ast.expr, bool] | None:
372
+ match body:
373
+ case [ast.If(test=test, body=[statement], orelse=[])]:
374
+ expression = _single_method_argument(statement, name, "append")
375
+ if expression is not None:
376
+ return expression, test, False
377
+ case [ast.If(test=test, body=[ast.Continue()], orelse=[]), statement]:
378
+ expression = _single_method_argument(statement, name, "append")
379
+ if expression is not None:
380
+ return expression, test, True
381
+ case _:
382
+ pass
272
383
  return None
273
384
 
274
385
 
386
+ def _computed_list_replacement(
387
+ loop: ast.For, name: str, bound_names: frozenset[str], owner: _Callable
388
+ ) -> _LoopReplacement | None:
389
+ match loop.body:
390
+ case [ast.Assign(targets=[ast.Name(id=candidate)], value=value), *tail]:
391
+ pass
392
+ case _:
393
+ return None
394
+
395
+ if candidate == name or candidate in bound_names:
396
+ return None
397
+ if _target_binding_is_observable(owner, frozenset({candidate}), loop):
398
+ return None
399
+ if _loads_name(value, name) or _contains_prohibited_expression(value):
400
+ return None
401
+
402
+ filtered = _filtered_list_parts(tail, name)
403
+ if filtered is None:
404
+ return None
405
+ expression, test, inverted = filtered
406
+ condition_kind = _computed_condition_kind(test, candidate, inverted=inverted)
407
+ if condition_kind is None:
408
+ return None
409
+ if _loads_name(expression, name) or not _loads_name(expression, candidate):
410
+ return None
411
+ projection_names = bound_names | frozenset({candidate})
412
+ if not _bounded_list_projection(expression, projection_names):
413
+ return None
414
+
415
+ assignment = f"({candidate} := {ast.unparse(value)})"
416
+ condition = assignment if condition_kind == "truthy" else f"{assignment} is not None"
417
+ return _LoopReplacement(
418
+ _CollectionKind.LIST,
419
+ f"[{ast.unparse(expression)} for {ast.unparse(loop.target)} in {ast.unparse(loop.iter)} if {condition}]",
420
+ "computes, filters, and appends to",
421
+ allow_multiline=True,
422
+ )
423
+
424
+
425
+ def _computed_condition_kind(test: ast.expr, candidate: str, *, inverted: bool) -> str | None:
426
+ if not inverted and isinstance(test, ast.Name) and test.id == candidate:
427
+ return "truthy"
428
+ if (
429
+ inverted
430
+ and isinstance(test, ast.UnaryOp)
431
+ and isinstance(test.op, ast.Not)
432
+ and isinstance(test.operand, ast.Name)
433
+ and test.operand.id == candidate
434
+ ):
435
+ return "truthy"
436
+ match test:
437
+ case ast.Compare(left=ast.Name(id=left), ops=[operator], comparators=[ast.Constant(value=None)]) if (
438
+ left == candidate
439
+ and ((not inverted and isinstance(operator, ast.IsNot)) or (inverted and isinstance(operator, ast.Is)))
440
+ ):
441
+ return "not-none"
442
+ case _:
443
+ return None
444
+
445
+
446
+ def _bounded_list_projection(node: ast.expr, names: frozenset[str]) -> bool:
447
+ if _simple_projection(node, names):
448
+ return True
449
+ match node:
450
+ case ast.Call(func=func, args=args, keywords=keywords):
451
+ if any(isinstance(argument, ast.Starred) for argument in args):
452
+ return False
453
+ if any(keyword.arg is None for keyword in keywords):
454
+ return False
455
+ if not _dotted_name(func):
456
+ return False
457
+ values = (*args, *(keyword.value for keyword in keywords))
458
+ return all(
459
+ not any(isinstance(item, ast.Call) for item in ast.walk(value))
460
+ and not _contains_prohibited_expression(value)
461
+ for value in values
462
+ )
463
+ case _:
464
+ return False
465
+
466
+
467
+ def _dotted_name(node: ast.expr) -> bool:
468
+ match node:
469
+ case ast.Name():
470
+ return True
471
+ case ast.Attribute(value=value):
472
+ return _dotted_name(value)
473
+ case _:
474
+ return False
475
+
476
+
477
+ def _condition_source(test: ast.expr, *, inverted: bool) -> str:
478
+ if not inverted:
479
+ return ast.unparse(test)
480
+ return ast.unparse(ast.UnaryOp(op=ast.Not(), operand=test))
481
+
482
+
275
483
  def _single_method_argument(statement: ast.stmt, name: str, method: str) -> ast.expr | None:
276
484
  match statement:
277
485
  case ast.Expr(
@@ -383,37 +591,19 @@ def _set_is_shadowed(tree: ast.Module) -> bool:
383
591
  return False
384
592
 
385
593
 
386
- def _replacement_fits(init: _Init, loop: ast.For, name: str, kind: _CollectionKind) -> bool:
387
- expression = _replacement_expression(loop, name, kind)
388
- if expression is None:
389
- return False
594
+ def _replacement_fits(init: _Init, name: str, expression: str, *, allow_multiline: bool = False) -> bool:
390
595
  prefix = f"{name} = "
391
596
  if isinstance(init, ast.AnnAssign):
392
597
  prefix = f"{name}: {ast.unparse(init.annotation)} = "
393
- return init.col_offset + len(prefix) + len(expression) <= _MAX_REPLACEMENT_WIDTH
394
-
395
-
396
- def _replacement_expression(loop: ast.For, name: str, kind: _CollectionKind) -> str | None:
397
- match kind, loop.body:
398
- case _CollectionKind.DICT, [ast.Assign(targets=[ast.Subscript(slice=key)], value=value)]:
399
- return (
400
- f"{{{ast.unparse(key)}: {ast.unparse(value)} for {ast.unparse(loop.target)} "
401
- f"in {ast.unparse(loop.iter)}}}"
402
- )
403
- case _CollectionKind.LIST, [statement]:
404
- argument = _single_method_argument(statement, name, "append")
405
- if argument is not None:
406
- return f"[{ast.unparse(argument)} for {ast.unparse(loop.target)} in {ast.unparse(loop.iter)}]"
407
- case _CollectionKind.SET, [ast.If(test=test, body=[statement], orelse=[])]:
408
- argument = _single_method_argument(statement, name, "add")
409
- if argument is not None:
410
- return (
411
- f"{{{ast.unparse(argument)} for {ast.unparse(loop.target)} in {ast.unparse(loop.iter)} "
412
- f"if {ast.unparse(test)}}}"
413
- )
414
- case _:
415
- pass
416
- return None
598
+ if init.col_offset + len(prefix) + len(expression) <= _MAX_REPLACEMENT_WIDTH:
599
+ return True
600
+ if not allow_multiline or not (expression.startswith("[") and expression.endswith("]")):
601
+ return False
602
+ content = expression[1:-1]
603
+ return (
604
+ init.col_offset + len(prefix) + 1 <= _MAX_REPLACEMENT_WIDTH
605
+ and init.col_offset + 4 + len(content) <= _MAX_REPLACEMENT_WIDTH
606
+ )
417
607
 
418
608
 
419
609
  def _comment_lines(source: str) -> frozenset[int]:
@@ -429,7 +619,7 @@ def _has_comment(init: _Init, loop: ast.For, comments: frozenset[int]) -> bool:
429
619
  return any(init.lineno <= line <= end_line for line in comments)
430
620
 
431
621
 
432
- def _set_loop_kind(loop: ast.For, name: str, bound_names: frozenset[str]) -> _CollectionKind | None:
622
+ def _set_replacement(loop: ast.For, name: str, bound_names: frozenset[str]) -> _LoopReplacement | None:
433
623
  match loop.body[0]:
434
624
  case ast.If(test=test, body=[body], orelse=[]):
435
625
  expression = _single_method_argument(body, name, "add")
@@ -440,7 +630,19 @@ def _set_loop_kind(loop: ast.For, name: str, bound_names: frozenset[str]) -> _Co
440
630
  and _simple_projection(expression, bound_names)
441
631
  and not _contains_prohibited_expression(test)
442
632
  ):
443
- return _CollectionKind.SET
633
+ return _LoopReplacement(
634
+ _CollectionKind.SET,
635
+ f"{{{ast.unparse(expression)} for {ast.unparse(loop.target)} in {ast.unparse(loop.iter)} "
636
+ f"if {ast.unparse(test)}}}",
637
+ )
444
638
  case _:
445
639
  pass
446
640
  return None
641
+
642
+
643
+ def _message(finding: _Candidate) -> str:
644
+ qualifier = "filtered " if finding.action != "populates" else ""
645
+ return (
646
+ f"This loop only {finding.action} fresh {finding.kind} {finding.name!r} — prefer a "
647
+ f"{qualifier}{finding.kind} comprehension."
648
+ )