sarj-python-lint 0.81.4__tar.gz → 0.81.5__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.5}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/prefer_class_row.py +85 -25
  4. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/.gitignore +0 -0
  5. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/LICENSE +0 -0
  6. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/README.md +0 -0
  7. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/__init__.py +0 -0
  8. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/__main__.py +0 -0
  9. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/_analysis_session.py +0 -0
  10. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/_filesystem.py +0 -0
  11. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/_python_target.py +0 -0
  12. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  13. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/_secret_names.py +0 -0
  14. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/_version.py +0 -0
  15. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/py.typed +0 -0
  16. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/ratchet.py +0 -0
  17. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rule_base.py +0 -0
  18. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/__init__.py +0 -0
  19. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  20. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/_comments.py +0 -0
  21. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  22. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  23. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/_first_party.py +0 -0
  24. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  25. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/_imports.py +0 -0
  26. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/_logging.py +0 -0
  27. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/_paths.py +0 -0
  28. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/_project_index.py +0 -0
  29. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  30. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/_pytest.py +0 -0
  31. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/_registry.py +0 -0
  32. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/_sql.py +0 -0
  33. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  34. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  35. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  36. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  37. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  38. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  39. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  40. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  41. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  42. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  43. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  44. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  45. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  46. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  47. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  48. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  49. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  50. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  51. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  52. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  53. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  54. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  55. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  56. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  57. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  58. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  59. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  60. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  61. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  62. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  63. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  64. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  65. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  66. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  67. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  68. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  69. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  70. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  71. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  72. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  73. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  74. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  75. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  76. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  77. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  78. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  79. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  80. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  81. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  82. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  83. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  84. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  85. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  86. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  87. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  88. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  89. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  90. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  91. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  92. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  93. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  94. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  95. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  96. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  97. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  98. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  99. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  100. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  101. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/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.5}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  103. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  104. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  105. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  106. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  107. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/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.5}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  109. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  110. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  111. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/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.5}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  113. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/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.5}/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.5}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  116. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/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.5}/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.5}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  119. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/stepdown.py +0 -0
  120. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/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.5}/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.5}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  123. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  124. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  125. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  126. {sarj_python_lint-0.81.4 → sarj_python_lint-0.81.5}/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.5
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.5"
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" }]
@@ -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: