sarj-python-lint 0.81.4__tar.gz → 0.81.6__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 (126) hide show
  1. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +1 -1
  4. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +1 -1
  5. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_typed_doc_sections.py +1 -1
  6. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +1 -1
  7. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/prefer_class_row.py +85 -25
  8. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +4 -4
  9. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +1 -1
  10. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +60 -6
  11. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/.gitignore +0 -0
  12. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/LICENSE +0 -0
  13. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/README.md +0 -0
  14. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/__init__.py +0 -0
  15. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/__main__.py +0 -0
  16. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/_analysis_session.py +0 -0
  17. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/_filesystem.py +0 -0
  18. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/_python_target.py +0 -0
  19. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  20. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/_secret_names.py +0 -0
  21. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/_version.py +0 -0
  22. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/py.typed +0 -0
  23. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/ratchet.py +0 -0
  24. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rule_base.py +0 -0
  25. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/__init__.py +0 -0
  26. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  27. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/_comments.py +0 -0
  28. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  29. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  30. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/_first_party.py +0 -0
  31. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  32. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/_imports.py +0 -0
  33. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/_logging.py +0 -0
  34. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/_paths.py +0 -0
  35. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/_project_index.py +0 -0
  36. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  37. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/_pytest.py +0 -0
  38. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/_registry.py +0 -0
  39. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/_sql.py +0 -0
  40. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  41. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  42. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  43. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  44. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  45. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  46. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  47. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  48. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  49. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  50. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  51. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  52. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  53. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  54. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  55. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  56. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  57. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  58. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  59. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  60. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  61. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  62. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  63. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  64. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  65. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  66. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  67. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  68. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  69. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  70. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  71. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  72. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  73. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  74. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  75. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  76. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  77. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  78. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  79. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  80. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  81. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  82. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  83. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  84. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  85. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  86. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  87. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  88. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  89. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  90. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  91. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  92. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  93. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  94. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  95. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  96. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  97. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  98. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  99. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  100. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  101. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  102. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  103. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  104. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  105. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  106. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  107. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  108. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  109. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  110. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  111. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  112. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  113. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  114. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  115. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  116. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  117. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  118. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  119. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/stepdown.py +0 -0
  120. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  121. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  122. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  123. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  124. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  125. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  126. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.6}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sarj-python-lint
3
- Version: 0.81.4
3
+ Version: 0.81.6
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.81.4"
3
+ version = "0.81.6"
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" }]
@@ -200,7 +200,7 @@ def _query_argument(call: ast.Call) -> ast.expr | None:
200
200
  tokens = frozenset(part for part in receiver.split("_") if part)
201
201
  if (
202
202
  receiver not in _QUERY_RECEIVER_TOKENS
203
- and not tokens.intersection(_QUERY_RECEIVER_TOKENS)
203
+ and tokens.isdisjoint(_QUERY_RECEIVER_TOKENS)
204
204
  and not any(receiver.endswith(suffix) for suffix in ("conn", "cur", "cursor", "session"))
205
205
  ):
206
206
  return None
@@ -761,7 +761,7 @@ def _representation_fixture_path(node: ast.AST) -> bool:
761
761
  if not isinstance(child, ast.Constant) or not isinstance(child.value, str):
762
762
  continue
763
763
  parts = {part.lower() for part in child.value.replace("\\", "/").split("/")}
764
- if parts & _REPRESENTATION_DIRS:
764
+ if not parts.isdisjoint(_REPRESENTATION_DIRS):
765
765
  return True
766
766
  return False
767
767
 
@@ -121,7 +121,7 @@ def _public_contract_docstring_lines(path: Path, source: str) -> frozenset[int]:
121
121
  protocol_method = isinstance(owner, ast.ClassDef) and any(
122
122
  _terminal_name(base) in _PUBLIC_CONTRACT_BASES for base in owner.bases
123
123
  )
124
- if decorators & _PUBLIC_CONTRACT_DECORATORS or protocol_method:
124
+ if not decorators.isdisjoint(_PUBLIC_CONTRACT_DECORATORS) or protocol_method:
125
125
  excluded.add(first.lineno)
126
126
  return frozenset(excluded)
127
127
 
@@ -647,7 +647,7 @@ def _schema_docstring_consumers(tree: ast.Module, imports: dict[str, str]) -> fr
647
647
  _resolve_imported_name(base.value if isinstance(base, ast.Subscript) else base, imports)
648
648
  for base in class_node.bases
649
649
  }
650
- if base_names & (_KNOWN_SCHEMA_BASES | consumed):
650
+ if not base_names.isdisjoint(_KNOWN_SCHEMA_BASES | consumed):
651
651
  consumed.add(name)
652
652
  changed = True
653
653
  return frozenset(id(classes[name]) for name in consumed)
@@ -36,6 +36,7 @@ _SCOPE_NODES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)
36
36
  class _BoundCursor(NamedTuple):
37
37
  variable: str
38
38
  factory: ast.expr
39
+ active_nodes: tuple[ast.AST, ...]
39
40
 
40
41
 
41
42
  class _FetchedValue(NamedTuple):
@@ -73,7 +74,7 @@ class PreferClassRow(Rule):
73
74
  files=(
74
75
  ExampleFile.python(
75
76
  "app/task_store.py",
76
- "from psycopg.rows import dict_row\n\nasync def load(conn):\n async with conn.cursor(row_factory=dict_row) as cursor:\n row = await cursor.fetchone()\n return Task.model_validate(row)\n",
77
+ 'from psycopg.rows import dict_row\n\nasync def load(conn):\n async with conn.cursor(row_factory=dict_row) as cursor:\n await cursor.execute("SELECT id, state FROM task")\n row = await cursor.fetchone()\n return Task.model_validate(row)\n',
77
78
  ),
78
79
  ),
79
80
  focus_path=PurePosixPath("app/task_store.py"),
@@ -112,7 +113,7 @@ class PreferClassRow(Rule):
112
113
  for cursor in _bound_cursors(function):
113
114
  if not imports.is_dict_row(cursor.factory, shadowed):
114
115
  continue
115
- models = _models_built_from_cursor(function, cursor.variable)
116
+ models = _models_built_from_cursor(cursor)
116
117
  if len(models) != 1:
117
118
  continue
118
119
  model = next(iter(models))
@@ -247,37 +248,78 @@ def _functions(tree: ast.Module) -> Iterator[ast.FunctionDef | ast.AsyncFunction
247
248
  return (node for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)))
248
249
 
249
250
 
250
- def _scope_nodes(function: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[ast.AST]:
251
- pending: list[ast.AST] = list(reversed(function.body))
252
- while pending:
253
- node = pending.pop()
254
- yield node
255
- if isinstance(node, _SCOPE_NODES):
256
- continue
257
- pending.extend(reversed(list(ast.iter_child_nodes(node))))
258
-
259
-
260
251
  def _bound_cursors(function: ast.FunctionDef | ast.AsyncFunctionDef) -> list[_BoundCursor]:
252
+ scoped_nodes = tuple(_nodes_in(function.body))
261
253
  cursors: list[_BoundCursor] = []
262
- for node in _scope_nodes(function):
254
+ for node in scoped_nodes:
263
255
  if isinstance(node, (ast.With, ast.AsyncWith)):
264
256
  for item in node.items:
265
257
  if isinstance(item.optional_vars, ast.Name):
266
- cursor = _cursor(item.context_expr, item.optional_vars.id)
258
+ active_nodes = _active_nodes(tuple(_nodes_in(node.body)), item.optional_vars.id, node.lineno)
259
+ cursor = _cursor(item.context_expr, item.optional_vars.id, active_nodes)
267
260
  if cursor is not None:
268
261
  cursors.append(cursor)
269
262
  elif isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
270
- cursor = _cursor(node.value, node.targets[0].id)
263
+ variable = node.targets[0].id
264
+ cursor = _cursor(node.value, variable, _active_nodes(scoped_nodes, variable, node.lineno))
271
265
  if cursor is not None:
272
266
  cursors.append(cursor)
273
267
  elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.value is not None:
274
- cursor = _cursor(node.value, node.target.id)
268
+ cursor = _cursor(
269
+ node.value,
270
+ node.target.id,
271
+ _active_nodes(scoped_nodes, node.target.id, node.lineno),
272
+ )
275
273
  if cursor is not None:
276
274
  cursors.append(cursor)
277
275
  return cursors
278
276
 
279
277
 
280
- def _cursor(expression: ast.expr, variable: str) -> _BoundCursor | None:
278
+ def _nodes_in(statements: Iterable[ast.stmt]) -> Iterator[ast.AST]:
279
+ pending: list[ast.AST] = list(reversed(tuple(statements)))
280
+ while pending:
281
+ node = pending.pop()
282
+ yield node
283
+ if isinstance(node, _SCOPE_NODES):
284
+ continue
285
+ pending.extend(reversed(list(ast.iter_child_nodes(node))))
286
+
287
+
288
+ def _active_nodes(nodes: tuple[ast.AST, ...], variable: str, start: int) -> tuple[ast.AST, ...]:
289
+ rebound_lines = [
290
+ node.lineno
291
+ for node in nodes
292
+ if isinstance(node, ast.stmt) and node.lineno > start and _binds_name(node, variable)
293
+ ]
294
+ end = min(rebound_lines, default=None)
295
+ return tuple(
296
+ node
297
+ for node in nodes
298
+ if getattr(node, "lineno", start) > start and (end is None or getattr(node, "lineno", end) < end)
299
+ )
300
+
301
+
302
+ def _binds_name(node: ast.stmt, name: str) -> bool:
303
+ if _writes_name(node, name):
304
+ return True
305
+ match node:
306
+ case ast.FunctionDef() | ast.AsyncFunctionDef() | ast.ClassDef():
307
+ return node.name == name
308
+ case ast.For() | ast.AsyncFor():
309
+ return _target_root(node.target) == name
310
+ case ast.With() | ast.AsyncWith():
311
+ return any(
312
+ item.optional_vars is not None and _target_root(item.optional_vars) == name for item in node.items
313
+ )
314
+ case ast.Import():
315
+ return any((alias.asname or alias.name.partition(".")[0]) == name for alias in node.names)
316
+ case ast.ImportFrom():
317
+ return any((alias.asname or alias.name) == name for alias in node.names)
318
+ case _:
319
+ return False
320
+
321
+
322
+ def _cursor(expression: ast.expr, variable: str, active_nodes: tuple[ast.AST, ...]) -> _BoundCursor | None:
281
323
  if not (
282
324
  isinstance(expression, ast.Call)
283
325
  and isinstance(expression.func, ast.Attribute)
@@ -285,18 +327,15 @@ def _cursor(expression: ast.expr, variable: str) -> _BoundCursor | None:
285
327
  ):
286
328
  return None
287
329
  factory = next((keyword.value for keyword in expression.keywords if keyword.arg == _ROW_FACTORY), None)
288
- return None if factory is None else _BoundCursor(variable, factory)
330
+ return None if factory is None else _BoundCursor(variable, factory, active_nodes)
289
331
 
290
332
 
291
333
  def _models_built_from_cursor(
292
- function: ast.FunctionDef | ast.AsyncFunctionDef,
293
- cursor: str,
334
+ cursor: _BoundCursor,
294
335
  ) -> frozenset[str]:
295
- scoped_nodes = list(_scope_nodes(function))
296
- fetched_values = _fetched_values(scoped_nodes, cursor)
297
- if not fetched_values:
298
- return frozenset()
299
- models: set[str] = set()
336
+ scoped_nodes = list(cursor.active_nodes)
337
+ fetched_values = _fetched_values(scoped_nodes, cursor.variable)
338
+ models = _models_from_direct_fetch(scoped_nodes, cursor.variable)
300
339
  for fetched in fetched_values:
301
340
  if fetched.many:
302
341
  converted = _models_from_collection(scoped_nodes, fetched)
@@ -308,6 +347,27 @@ def _models_built_from_cursor(
308
347
  return frozenset(models)
309
348
 
310
349
 
350
+ def _models_from_direct_fetch(scoped_nodes: list[ast.AST], cursor: str) -> set[str]:
351
+ models: set[str] = set()
352
+ for node in scoped_nodes:
353
+ if not isinstance(node, ast.Call):
354
+ continue
355
+ if isinstance(node.func, ast.Attribute) and node.func.attr == _MODEL_VALIDATE:
356
+ if len(node.args) != 1 or node.keywords or _fetch_method(node.args[0], cursor) != "fetchone":
357
+ continue
358
+ model = _model_name(node.func.value)
359
+ else:
360
+ direct_rows = [
361
+ keyword.value
362
+ for keyword in node.keywords
363
+ if keyword.arg is None and _fetch_method(keyword.value, cursor) == "fetchone"
364
+ ]
365
+ model = _model_name(node.func) if len(direct_rows) == 1 else None
366
+ if model is not None:
367
+ models.add(model)
368
+ return models
369
+
370
+
311
371
  def _fetched_values(scoped_nodes: list[ast.AST], cursor: str) -> list[_FetchedValue]:
312
372
  fetched: list[_FetchedValue] = []
313
373
  for node in scoped_nodes:
@@ -299,15 +299,15 @@ def _name_role(identifier: str) -> _Role:
299
299
  def _is_authenticator_name(identifier: str) -> bool:
300
300
  tokens = identifier_tokens(identifier)
301
301
  token_set = set(tokens)
302
- is_auth_header = "header" in token_set and bool(token_set & {"auth", "authorization"})
302
+ is_auth_header = "header" in token_set and not token_set.isdisjoint({"auth", "authorization"})
303
303
  if not is_secret_name(identifier) and not is_auth_header:
304
304
  return False
305
305
  if (token_set == {"token"} and not identifier.isupper()) or not tokens:
306
306
  return False
307
- if tokens[-1] in _DESCRIPTOR_WORDS or token_set & _CATEGORY_WORDS:
307
+ if tokens[-1] in _DESCRIPTOR_WORDS or not token_set.isdisjoint(_CATEGORY_WORDS):
308
308
  return False
309
- return bool(
310
- token_set & _AUTH_WORDS
309
+ return (
310
+ not token_set.isdisjoint(_AUTH_WORDS)
311
311
  or is_auth_header
312
312
  or any(left == "api" and right == "key" for left, right in pairwise(tokens))
313
313
  )
@@ -431,7 +431,7 @@ def _missing_unit(statement: ast.Assign | ast.AnnAssign, name: str, comment: str
431
431
  # A name carrying a different unit is contradictory, but appending a second
432
432
  # suffix would worsen it; leave the conflict to a dedicated future rule.
433
433
  mentioned_name_tokens = frozenset(token for unit in mentioned for token in unit.name_tokens)
434
- if declaration_tokens & (_CONFLICT_UNIT_NAME_TOKENS - mentioned_name_tokens):
434
+ if not declaration_tokens.isdisjoint(_CONFLICT_UNIT_NAME_TOKENS - mentioned_name_tokens):
435
435
  return None
436
436
  # A rationale can mention several quantities; once the name identifies one,
437
437
  # guessing that another quantity is the assigned value is unsafe.
@@ -23,6 +23,24 @@ if TYPE_CHECKING:
23
23
  from pathlib import Path
24
24
 
25
25
 
26
+ _TRACKED_BUILTINS = frozenset(
27
+ {
28
+ "bool",
29
+ "dict",
30
+ "enumerate",
31
+ "filter",
32
+ "frozenset",
33
+ "list",
34
+ "map",
35
+ "range",
36
+ "reversed",
37
+ "set",
38
+ "tuple",
39
+ "zip",
40
+ }
41
+ )
42
+
43
+
26
44
  @final
27
45
  class PreferSetIsdisjoint(Rule):
28
46
  id = "prefer-set-isdisjoint"
@@ -35,7 +53,7 @@ class PreferSetIsdisjoint(Rule):
35
53
  autofix=AutofixPolicy.SUGGESTION,
36
54
  limitations=(
37
55
  "Built-in set identity must be proven from a literal, comprehension, constructor, or one dominating local assignment.",
38
- "Both intersection operands must be proven built-in sets; annotations, parameters, attributes, subclasses, branch-merged bindings, stored intersections, and generated files are excluded.",
56
+ "For binary `&`, the left operand must be a proven built-in set and the right operand must be a literal, comprehension, built-in iterable constructor, standard no-argument collection view, or proven set; annotations, parameters, attributes, subclasses, branch-merged bindings, stored intersections, and generated files are excluded.",
39
57
  "The suggestion is intentionally not an autofix because short-circuiting may make custom element equality or hashing side effects observable.",
40
58
  ),
41
59
  examples=(
@@ -185,6 +203,8 @@ class _Scanner:
185
203
  if isinstance(expression, ast.UnaryOp) and isinstance(expression.op, ast.Not):
186
204
  negated = True
187
205
  candidate = expression.operand
206
+ if isinstance(candidate, ast.Call) and _is_builtin_bool_call(candidate, self.shadowed):
207
+ candidate = candidate.args[0]
188
208
  if _is_intersection(candidate, safe_exact, self.shadowed):
189
209
  self._report(candidate, negated=negated)
190
210
 
@@ -246,7 +266,7 @@ class _Scanner:
246
266
 
247
267
  def _is_intersection(node: ast.expr, exact: set[str], shadowed: frozenset[str]) -> bool:
248
268
  if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitAnd):
249
- return _is_exact_set(node.left, exact, shadowed) and _is_exact_set(node.right, exact, shadowed)
269
+ return _is_exact_set(node.left, exact, shadowed) and _is_proven_iterable(node.right, exact, shadowed)
250
270
  return (
251
271
  isinstance(node, ast.Call)
252
272
  and isinstance(node.func, ast.Attribute)
@@ -254,7 +274,17 @@ def _is_intersection(node: ast.expr, exact: set[str], shadowed: frozenset[str])
254
274
  and len(node.args) == 1
255
275
  and not node.keywords
256
276
  and _is_exact_set(node.func.value, exact, shadowed)
257
- and _is_exact_set(node.args[0], exact, shadowed)
277
+ )
278
+
279
+
280
+ def _is_builtin_bool_call(node: ast.expr, shadowed: frozenset[str]) -> bool:
281
+ return (
282
+ isinstance(node, ast.Call)
283
+ and isinstance(node.func, ast.Name)
284
+ and node.func.id == "bool"
285
+ and node.func.id not in shadowed
286
+ and len(node.args) == 1
287
+ and not node.keywords
258
288
  )
259
289
 
260
290
 
@@ -273,6 +303,30 @@ def _is_exact_set(node: ast.expr, exact: set[str], shadowed: frozenset[str]) ->
273
303
  )
274
304
 
275
305
 
306
+ def _is_proven_iterable(node: ast.expr, exact: set[str], shadowed: frozenset[str]) -> bool:
307
+ if _is_exact_set(node, exact, shadowed):
308
+ return True
309
+ if isinstance(
310
+ node,
311
+ ast.List | ast.Tuple | ast.Dict | ast.ListComp | ast.DictComp | ast.GeneratorExp | ast.Constant,
312
+ ):
313
+ return not isinstance(node, ast.Constant) or isinstance(node.value, str | bytes)
314
+ if (
315
+ isinstance(node, ast.Call)
316
+ and isinstance(node.func, ast.Attribute)
317
+ and node.func.attr in {"items", "keys", "values"}
318
+ and not node.args
319
+ and not node.keywords
320
+ ):
321
+ return True
322
+ return (
323
+ isinstance(node, ast.Call)
324
+ and isinstance(node.func, ast.Name)
325
+ and node.func.id in {"dict", "enumerate", "filter", "list", "map", "range", "reversed", "tuple", "zip"}
326
+ and node.func.id not in shadowed
327
+ )
328
+
329
+
276
330
  def _stored_names(node: ast.AST) -> set[str]:
277
331
  return {child.id for child in ast.walk(node) if isinstance(child, ast.Name) and isinstance(child.ctx, ast.Store)}
278
332
 
@@ -297,10 +351,10 @@ def _argument_names(arguments: ast.arguments) -> set[str]:
297
351
  def _shadowed_builtins(tree: ast.Module) -> frozenset[str]:
298
352
  shadowed: set[str] = set()
299
353
  for node in ast.walk(tree):
300
- if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) and node.id in {"set", "frozenset"}:
354
+ if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) and node.id in _TRACKED_BUILTINS:
301
355
  shadowed.add(node.id)
302
- elif isinstance(node, ast.arg) and node.arg in {"set", "frozenset"}:
356
+ elif isinstance(node, ast.arg) and node.arg in _TRACKED_BUILTINS:
303
357
  shadowed.add(node.arg)
304
- elif isinstance(node, ast.alias) and (node.asname or node.name.split(".")[0]) in {"set", "frozenset"}:
358
+ elif isinstance(node, ast.alias) and (node.asname or node.name.split(".")[0]) in _TRACKED_BUILTINS:
305
359
  shadowed.add(node.asname or node.name.split(".")[0])
306
360
  return frozenset(shadowed)