sarj-python-lint 0.80.5__tar.gz → 0.80.8__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 (170) hide show
  1. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/PKG-INFO +2 -1
  2. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/pyproject.toml +2 -2
  3. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/__main__.py +3 -5
  4. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_fastapi.py +12 -0
  5. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/_fixed_record.py +151 -0
  6. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/_imports.py +181 -0
  7. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_prose_budget.py +121 -11
  8. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_registry.py +15 -29
  9. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_sql.py +26 -5
  10. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +505 -0
  11. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +144 -54
  12. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/docstring_args_restate_signature.py +215 -0
  13. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +326 -0
  14. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/fakes_in_shared_location.py +64 -13
  15. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +175 -23
  16. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/iac_source_coupled_test.py +27 -10
  17. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +219 -14
  18. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/mock_without_spec.py +255 -35
  19. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +103 -42
  20. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +187 -0
  21. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +33 -21
  22. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +270 -0
  23. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/no_offset_pagination.py +167 -0
  24. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +902 -0
  25. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +804 -0
  26. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +912 -0
  27. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_redundant_literal_description.py +124 -49
  28. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_repeated_string_literal.py +83 -41
  29. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_repeated_test_body.py +41 -50
  30. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_restated_comment.py +56 -18
  31. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/no_secret_in_log.py +183 -0
  32. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/no_select_star.py +258 -0
  33. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +222 -206
  34. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/no_tautological_expect.py +218 -0
  35. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/no_typed_doc_sections.py +134 -0
  36. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/no_unique_violation_message_match.py +399 -0
  37. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/no_vague_suppression_description.py +138 -0
  38. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +202 -0
  39. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +298 -0
  40. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/over_mocked_test.py +216 -51
  41. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_class_row.py +432 -0
  42. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +507 -0
  43. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +246 -87
  44. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +115 -27
  45. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/prefer_library_fake.py +253 -86
  46. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_match_assert_never.py +436 -0
  47. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +449 -0
  48. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/prefer_module_level_constant.py +254 -110
  49. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +205 -0
  50. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_nominal_id_types.py +546 -0
  51. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +336 -0
  52. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/prefer_or_pattern.py +72 -34
  53. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +209 -34
  54. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_self_type_annotation.py +652 -0
  55. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/prefer_str_enum.py +301 -128
  56. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +368 -0
  57. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +265 -0
  58. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +506 -0
  59. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +249 -0
  60. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/production_derived_test_cases.py +151 -44
  61. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/pydantic_at_boundaries.py +313 -0
  62. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +181 -54
  63. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/redundant_class_docstring.py +209 -0
  64. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/redundant_docstring.py +269 -0
  65. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/redundant_module_docstring.py +79 -41
  66. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +273 -0
  67. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/repeated_static_call_cases.py +512 -0
  68. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +219 -18
  69. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +151 -51
  70. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/require_port_for_service.py +250 -51
  71. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +1208 -0
  72. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +614 -0
  73. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/restated_test_docstring.py +545 -0
  74. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +103 -22
  75. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +315 -0
  76. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +68 -15
  77. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/_imports.py +0 -90
  78. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -140
  79. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -201
  80. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -212
  81. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -299
  82. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/no_long_comment.py +0 -114
  83. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -171
  84. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/no_offset_pagination.py +0 -118
  85. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -202
  86. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -155
  87. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/no_secret_in_log.py +0 -171
  88. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/no_select_star.py +0 -138
  89. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -580
  90. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -167
  91. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/no_tautological_expect.py +0 -279
  92. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -86
  93. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -170
  94. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -107
  95. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -209
  96. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/prefer_class_row.py +0 -231
  97. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -332
  98. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -440
  99. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -978
  100. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -493
  101. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -475
  102. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -383
  103. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/prefer_one_for_required_row.py +0 -175
  104. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -250
  105. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -131
  106. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -669
  107. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -171
  108. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -298
  109. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -149
  110. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/preserve_declared_nominal_id.py +0 -138
  111. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/preserve_enum_types.py +0 -131
  112. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -374
  113. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -121
  114. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/redundant_docstring.py +0 -171
  115. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -206
  116. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -305
  117. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -601
  118. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -166
  119. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/require_validated_row_factory.py +0 -159
  120. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/restated_test_docstring.py +0 -248
  121. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/source_coupled_test.py +0 -503
  122. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/sql_requires_injected_pool_owner.py +0 -202
  123. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -148
  124. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/.gitignore +0 -0
  125. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/LICENSE +0 -0
  126. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/README.md +0 -0
  127. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/__init__.py +0 -0
  128. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/_analysis_session.py +0 -0
  129. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/_filesystem.py +0 -0
  130. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  131. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/_secret_names.py +0 -0
  132. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/_version.py +0 -0
  133. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/py.typed +0 -0
  134. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/ratchet.py +0 -0
  135. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rule_base.py +0 -0
  136. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/__init__.py +0 -0
  137. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  138. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_comments.py +0 -0
  139. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  140. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_first_party.py +0 -0
  141. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_logging.py +0 -0
  142. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_paths.py +0 -0
  143. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_project_index.py +0 -0
  144. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_pytest.py +0 -0
  145. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  146. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  147. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  148. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  149. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  150. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  151. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  152. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  153. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  154. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  155. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  156. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  157. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  158. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  159. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  160. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  161. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  162. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  163. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  164. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  165. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/stepdown.py +0 -0
  166. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  167. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  168. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  169. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  170. {sarj_python_lint-0.80.5 → sarj_python_lint-0.80.8}/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.80.5
3
+ Version: 0.80.8
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/
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.14
18
18
  Classifier: Topic :: Software Development :: Quality Assurance
19
19
  Requires-Python: >=3.14
20
20
  Requires-Dist: ruff>=0.16.5
21
+ Requires-Dist: sqlglot==30.18.0
21
22
  Description-Content-Type: text/markdown
22
23
 
23
24
  <!-- Generated by `code-standards maintain docs sync`; do not edit. -->
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.80.5"
3
+ version = "0.80.8"
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" }]
@@ -15,7 +15,7 @@ classifiers = [
15
15
  "Programming Language :: Python :: 3.14",
16
16
  "Topic :: Software Development :: Quality Assurance",
17
17
  ]
18
- dependencies = ["ruff>=0.16.5"]
18
+ dependencies = ["ruff>=0.16.5", "sqlglot==30.18.0"]
19
19
 
20
20
  [project.scripts]
21
21
  sarj-python-lint = "sarj_python_lint.__main__:main"
@@ -125,14 +125,12 @@ def analyze(
125
125
 
126
126
  _DIAGNOSTIC_PRECEDENCE = MappingProxyType(
127
127
  {
128
- "SARJ003": frozenset({"SARJ080"}),
129
128
  "SARJ050": frozenset({"SARJ420"}),
130
- "SARJ084": frozenset({"SARJ050", "SARJ091", "SARJ420"}),
129
+ "SARJ084": frozenset({"SARJ050", "SARJ420"}),
131
130
  "SARJ085": frozenset({"SARJ420"}),
132
131
  "SARJ086": frozenset({"SARJ420"}),
133
132
  "SARJ087": frozenset({"SARJ420"}),
134
- "SARJ088": frozenset({"SARJ050", "SARJ085", "SARJ091", "SARJ420"}),
135
- "SARJ091": frozenset({"SARJ420"}),
133
+ "SARJ088": frozenset({"SARJ050", "SARJ085", "SARJ420"}),
136
134
  "SARJ092": frozenset({"SARJ086", "SARJ087", "SARJ420"}),
137
135
  "SARJ093": frozenset({"SARJ034"}),
138
136
  "SARJ099": frozenset({"SARJ420"}),
@@ -140,7 +138,7 @@ _DIAGNOSTIC_PRECEDENCE = MappingProxyType(
140
138
  )
141
139
 
142
140
  _DOCSTRING_PRECEDENCE_CODES = frozenset(
143
- {"SARJ050", "SARJ084", "SARJ085", "SARJ086", "SARJ087", "SARJ088", "SARJ091", "SARJ092", "SARJ099"}
141
+ {"SARJ050", "SARJ084", "SARJ085", "SARJ086", "SARJ087", "SARJ088", "SARJ092", "SARJ099"}
144
142
  )
145
143
 
146
144
 
@@ -41,6 +41,8 @@ class Route:
41
41
  path: str | None
42
42
  receiver_kind: Literal["FastAPI", "APIRouter"]
43
43
  inherited_hidden: bool = False
44
+ inherited_responses: ast.expr | None = None
45
+ inherited_response_class: ast.expr | None = None
44
46
 
45
47
  @property
46
48
  def keywords(self) -> dict[str, ast.expr]:
@@ -119,6 +121,7 @@ class FastapiIndex:
119
121
  self.receiver_origins: dict[_ReceiverKey, _ReceiverKey] = {}
120
122
  self.receiver_kinds: dict[_ReceiverKey, Literal["FastAPI", "APIRouter"]] = {}
121
123
  self.hidden_receivers: set[_ReceiverKey] = set()
124
+ self.receiver_defaults: dict[_ReceiverKey, dict[str, ast.expr]] = {}
122
125
  self.decorators: dict[_ReceiverKey, _DecoratorBinding] = {}
123
126
  self.bound_names: set[_ReceiverKey] = set()
124
127
  self._node_scopes: dict[int, int] = {}
@@ -243,6 +246,12 @@ class FastapiIndex:
243
246
  self.receivers.add(key)
244
247
  self.receiver_origins[key] = key
245
248
  self.receiver_kinds[key] = constructor_kind
249
+ if isinstance(value, ast.Call):
250
+ self.receiver_defaults[key] = {
251
+ keyword.arg: keyword.value
252
+ for keyword in value.keywords
253
+ if keyword.arg in {"responses", "default_response_class"}
254
+ }
246
255
  if self._constructor_is_hidden(value):
247
256
  self.hidden_receivers.add(key)
248
257
  changed = True
@@ -346,6 +355,7 @@ class FastapiIndex:
346
355
  path = path_node.value if isinstance(path_node, ast.Constant) and isinstance(path_node.value, str) else None
347
356
  route_methods = self._route_methods(decorator) if method == "api_route" else (method,)
348
357
  origin = self.receiver_origins[receiver_key]
358
+ defaults = self.receiver_defaults.get(origin, {})
349
359
  routes.extend(
350
360
  Route(
351
361
  decorator=decorator,
@@ -354,6 +364,8 @@ class FastapiIndex:
354
364
  path=path,
355
365
  receiver_kind=self.receiver_kinds[receiver_key],
356
366
  inherited_hidden=receiver_key in self.hidden_receivers,
367
+ inherited_responses=defaults.get("responses"),
368
+ inherited_response_class=defaults.get("default_response_class"),
357
369
  )
358
370
  for route_method in route_methods
359
371
  )
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+
5
+ from sarj_python_lint.rules._ast_index import children
6
+
7
+
8
+ _RECORD_MUTATOR_METHODS = frozenset({"clear", "pop", "popitem", "setdefault", "update"})
9
+
10
+
11
+ def builds_fixed_record(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
12
+ returned: list[ast.expr] = []
13
+ record_names = {
14
+ target.id
15
+ for current in _owned_nodes(node)
16
+ if isinstance(current, ast.Assign) and _is_record_literal(current.value)
17
+ for target in current.targets
18
+ if isinstance(target, ast.Name)
19
+ }
20
+ record_names.update(
21
+ current.target.id
22
+ for current in _owned_nodes(node)
23
+ if isinstance(current, ast.AnnAssign)
24
+ and isinstance(current.target, ast.Name)
25
+ and current.value is not None
26
+ and _is_record_literal(current.value)
27
+ )
28
+ invalidated_names: set[str] = set()
29
+ aliases: dict[str, str] = {}
30
+ stack: list[ast.AST] = list(node.body)
31
+ while stack:
32
+ current = stack.pop()
33
+ if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
34
+ continue
35
+ if isinstance(current, ast.Return) and current.value is not None:
36
+ returned.append(current.value)
37
+ elif isinstance(current, ast.Assign) and _is_record_literal(current.value):
38
+ for target in current.targets:
39
+ if isinstance(target, ast.Subscript):
40
+ invalidated_names.update(_mutated_record_roots(target))
41
+ elif isinstance(current, ast.Assign):
42
+ if isinstance(current.value, ast.Name):
43
+ for target in current.targets:
44
+ if not isinstance(target, ast.Name):
45
+ continue
46
+ if target.id in record_names:
47
+ invalidated_names.add(target.id)
48
+ else:
49
+ aliases[target.id] = current.value.id
50
+ else:
51
+ invalidated_names.update(
52
+ target.id for target in current.targets if isinstance(target, ast.Name)
53
+ )
54
+ for target in current.targets:
55
+ if isinstance(target, ast.Subscript):
56
+ invalidated_names.update(_mutated_record_roots(target))
57
+ elif (
58
+ isinstance(current, ast.AnnAssign)
59
+ and isinstance(current.target, ast.Name)
60
+ and current.value is not None
61
+ and _is_record_literal(current.value)
62
+ ):
63
+ pass
64
+ elif (
65
+ isinstance(current, ast.AnnAssign)
66
+ and isinstance(current.target, ast.Name)
67
+ and isinstance(current.value, ast.Name)
68
+ ):
69
+ if current.target.id in record_names:
70
+ invalidated_names.add(current.target.id)
71
+ else:
72
+ aliases[current.target.id] = current.value.id
73
+ elif isinstance(current, (ast.AnnAssign, ast.AugAssign)):
74
+ invalidated_names.update(_mutated_record_roots(current.target))
75
+ elif isinstance(current, ast.Delete):
76
+ for target in current.targets:
77
+ invalidated_names.update(_mutated_record_roots(target))
78
+ elif (
79
+ isinstance(current, ast.Call)
80
+ and isinstance(current.func, ast.Attribute)
81
+ and current.func.attr in _RECORD_MUTATOR_METHODS
82
+ and isinstance(current.func.value, ast.Name)
83
+ ):
84
+ invalidated_names.add(current.func.value.id)
85
+ elif isinstance(current, ast.Call):
86
+ invalidated_names.update(
87
+ argument.id
88
+ for argument in (*current.args, *(keyword.value for keyword in current.keywords))
89
+ if isinstance(argument, ast.Name)
90
+ )
91
+ stack.extend(children(current))
92
+
93
+ changed = True
94
+ while changed:
95
+ changed = False
96
+ for alias, original in aliases.items():
97
+ if alias in invalidated_names and original not in invalidated_names:
98
+ invalidated_names.add(original)
99
+ changed = True
100
+ if original in invalidated_names and alias not in invalidated_names:
101
+ invalidated_names.add(alias)
102
+ changed = True
103
+
104
+ def original_name(name: str) -> str:
105
+ seen: set[str] = set()
106
+ while name in aliases and name not in seen:
107
+ seen.add(name)
108
+ name = aliases[name]
109
+ return name
110
+
111
+ intact_record_names = record_names - invalidated_names
112
+ if any(
113
+ isinstance(value, ast.Name) and original_name(value.id) in record_names & invalidated_names
114
+ for value in returned
115
+ ):
116
+ return False
117
+ return any(
118
+ _is_record_literal(value)
119
+ or (isinstance(value, ast.Name) and original_name(value.id) in intact_record_names)
120
+ for value in returned
121
+ )
122
+
123
+
124
+ def _owned_nodes(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[ast.AST]:
125
+ owned: list[ast.AST] = []
126
+ stack: list[ast.AST] = list(node.body)
127
+ while stack:
128
+ current = stack.pop()
129
+ if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
130
+ continue
131
+ owned.append(current)
132
+ stack.extend(children(current))
133
+ return owned
134
+
135
+
136
+ def _is_record_literal(node: ast.expr) -> bool:
137
+ if isinstance(node, ast.List):
138
+ return bool(node.elts) and all(_is_record_literal(element) for element in node.elts)
139
+ if isinstance(node, ast.ListComp):
140
+ return _is_record_literal(node.elt)
141
+ if not isinstance(node, ast.Dict):
142
+ return False
143
+ return bool(node.keys) and all(
144
+ isinstance(key, ast.Constant) and isinstance(key.value, str) for key in node.keys
145
+ )
146
+
147
+
148
+ def _mutated_record_roots(target: ast.AST) -> set[str]:
149
+ while isinstance(target, ast.Subscript):
150
+ target = target.value
151
+ return {target.id} if isinstance(target, ast.Name) else set()
@@ -0,0 +1,181 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass
6
+ from types import MappingProxyType
7
+ from typing import Self
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class _ImportTarget:
12
+ module: str
13
+ symbol: str | None
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class ImportIndex:
18
+ bindings: Mapping[str, _ImportTarget]
19
+ shadowed_names: frozenset[str]
20
+
21
+ @classmethod
22
+ def from_tree(cls, tree: ast.Module, *, module_scope_only: bool = False) -> Self:
23
+ candidates: dict[str, set[_ImportTarget]] = {}
24
+ statements = _module_import_statements(tree) if module_scope_only else tree.body
25
+ for statement in statements:
26
+ match statement:
27
+ case ast.Import(names=names):
28
+ for alias in names:
29
+ local = alias.asname or alias.name.partition(".")[0]
30
+ module = alias.name if alias.asname else alias.name.partition(".")[0]
31
+ candidates.setdefault(local, set()).add(_ImportTarget(module, None))
32
+ case ast.ImportFrom(module=str(module), names=names, level=0):
33
+ for alias in names:
34
+ if alias.name == "*":
35
+ continue
36
+ local = alias.asname or alias.name
37
+ candidates.setdefault(local, set()).add(_ImportTarget(module, alias.name))
38
+ case _:
39
+ pass
40
+
41
+ imported_names = frozenset(candidates)
42
+ non_import_bindings = (
43
+ _module_non_import_bindings(tree) if module_scope_only else _non_import_bindings(tree)
44
+ )
45
+ rebound = non_import_bindings & imported_names
46
+ bindings = {
47
+ local: next(iter(targets))
48
+ for local, targets in candidates.items()
49
+ if len(targets) == 1 and local not in rebound
50
+ }
51
+ return cls(MappingProxyType(bindings), frozenset(non_import_bindings))
52
+
53
+ def resolves(self, node: ast.expr, *, sources: frozenset[str], symbol: str) -> bool:
54
+ resolved = self._resolve(node)
55
+ return resolved is not None and resolved.module in sources and resolved.symbol == symbol
56
+
57
+ def resolved_symbol(self, node: ast.expr, *, sources: frozenset[str]) -> str | None:
58
+ resolved = self._resolve(node)
59
+ if resolved is None or resolved.module not in sources:
60
+ return None
61
+ return resolved.symbol
62
+
63
+ def builtin_is_unshadowed(self, name: str) -> bool:
64
+ return name not in self.shadowed_names and name not in self.bindings
65
+
66
+ def _resolve(self, node: ast.expr) -> _ImportTarget | None:
67
+ match node:
68
+ case ast.Name(id=local):
69
+ target = self.bindings.get(local)
70
+ return target if target is not None and target.symbol is not None else None
71
+ case ast.Attribute():
72
+ parts: list[str] = []
73
+ current: ast.expr = node
74
+ while isinstance(current, ast.Attribute):
75
+ parts.append(current.attr)
76
+ current = current.value
77
+ if not isinstance(current, ast.Name):
78
+ return None
79
+ target = self.bindings.get(current.id)
80
+ if target is None or target.symbol is not None or not parts:
81
+ return None
82
+ ordered = list(reversed(parts))
83
+ return _ImportTarget(".".join((target.module, *ordered[:-1])), ordered[-1])
84
+ case _:
85
+ return None
86
+
87
+
88
+ def _non_import_bindings(tree: ast.Module) -> set[str]:
89
+ names: set[str] = set()
90
+ for node in ast.walk(tree):
91
+ match node:
92
+ case ast.Name(id=name, ctx=(ast.Store() | ast.Del())) | ast.arg(arg=name):
93
+ names.add(name)
94
+ case ast.FunctionDef() | ast.AsyncFunctionDef() | ast.ClassDef():
95
+ names.add(node.name)
96
+ case ast.alias():
97
+ # Imports are the candidate bindings, not shadows of themselves.
98
+ continue
99
+ case _:
100
+ pass
101
+ return names
102
+
103
+
104
+ def _module_non_import_bindings(tree: ast.Module) -> set[str]:
105
+ names: set[str] = set()
106
+ for statement in tree.body:
107
+ match statement:
108
+ case ast.FunctionDef() | ast.AsyncFunctionDef() | ast.ClassDef():
109
+ names.add(statement.name)
110
+ case ast.Assign(targets=targets):
111
+ for target in targets:
112
+ names.update(_target_names(target))
113
+ case ast.AnnAssign(target=target) | ast.AugAssign(target=target):
114
+ names.update(_target_names(target))
115
+ case ast.TypeAlias(name=ast.Name(id=name)):
116
+ names.add(name)
117
+ case ast.Import() | ast.ImportFrom():
118
+ continue
119
+ case _:
120
+ # Module control flow can still bind names in the module namespace.
121
+ _collect_module_bindings(statement, names)
122
+ return names
123
+
124
+
125
+ def _target_names(node: ast.expr) -> set[str]:
126
+ if isinstance(node, ast.Name):
127
+ return {node.id}
128
+ if isinstance(node, (ast.Tuple, ast.List)):
129
+ return {name for element in node.elts for name in _target_names(element)}
130
+ return set()
131
+
132
+
133
+ def _collect_module_bindings(node: ast.AST, names: set[str]) -> None:
134
+ match node:
135
+ case ast.FunctionDef() | ast.AsyncFunctionDef() | ast.ClassDef():
136
+ names.add(node.name)
137
+ return
138
+ case ast.ListComp() | ast.SetComp() | ast.GeneratorExp():
139
+ _collect_module_bindings(node.elt, names)
140
+ _collect_comprehension_bindings(node.generators, names)
141
+ return
142
+ case ast.DictComp():
143
+ _collect_module_bindings(node.key, names)
144
+ _collect_module_bindings(node.value, names)
145
+ _collect_comprehension_bindings(node.generators, names)
146
+ return
147
+ case ast.Lambda():
148
+ return
149
+ case ast.Name(id=name, ctx=(ast.Store() | ast.Del())):
150
+ names.add(name)
151
+ case _:
152
+ pass
153
+ for child in ast.iter_child_nodes(node):
154
+ _collect_module_bindings(child, names)
155
+
156
+
157
+ def _collect_comprehension_bindings(generators: list[ast.comprehension], names: set[str]) -> None:
158
+ for generator in generators:
159
+ _collect_module_bindings(generator.iter, names)
160
+ for condition in generator.ifs:
161
+ _collect_module_bindings(condition, names)
162
+
163
+
164
+ def _module_import_statements(tree: ast.Module) -> list[ast.stmt]:
165
+ statements = list(tree.body)
166
+ for statement in tree.body:
167
+ if not isinstance(statement, ast.If) or not _is_type_checking_guard(statement.test):
168
+ continue
169
+ statements.extend(
170
+ child for child in statement.body if isinstance(child, (ast.Import, ast.ImportFrom))
171
+ )
172
+ return statements
173
+
174
+
175
+ def _is_type_checking_guard(node: ast.expr) -> bool:
176
+ return (isinstance(node, ast.Name) and node.id == "TYPE_CHECKING") or (
177
+ isinstance(node, ast.Attribute)
178
+ and isinstance(node.value, ast.Name)
179
+ and node.value.id in {"typing", "typing_extensions"}
180
+ and node.attr == "TYPE_CHECKING"
181
+ )
@@ -198,7 +198,7 @@ def _docstring_groups(tree: ast.Module) -> list[ProseGroup]:
198
198
  ):
199
199
  continue
200
200
  if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) and (
201
- decorator_markers(node) & PROMPT_DECORATOR_MARKERS
201
+ _decorator_terminal_names(node) & PROMPT_DECORATOR_MARKERS
202
202
  ):
203
203
  continue
204
204
  if isinstance(node, ast.ClassDef) and _is_schema_class(node):
@@ -244,6 +244,17 @@ def _is_schema_class(node: ast.ClassDef) -> bool:
244
244
  return bool(bases & _SCHEMA_BASES or decorator_markers(node) & _SCHEMA_DECORATORS)
245
245
 
246
246
 
247
+ def _decorator_terminal_names(node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef) -> frozenset[str]:
248
+ names: set[str] = set()
249
+ for decorator in node.decorator_list:
250
+ target = decorator.func if isinstance(decorator, ast.Call) else decorator
251
+ if isinstance(target, ast.Name):
252
+ names.add(target.id.casefold())
253
+ elif isinstance(target, ast.Attribute):
254
+ names.add(target.attr.casefold())
255
+ return frozenset(names)
256
+
257
+
247
258
  def _fully_typed(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
248
259
  args = node.args
249
260
  parameters = (*args.posonlyargs, *args.args, *args.kwonlyargs)
@@ -270,21 +281,23 @@ def _typed_restatement_lines(
270
281
  docstring_line: int,
271
282
  ) -> tuple[int, ...]:
272
283
  annotations = {
273
- arg.arg: _normalise_type(ast.unparse(arg.annotation))
284
+ arg.arg: _annotation_type(arg.annotation)
274
285
  for arg in (*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs)
275
286
  if arg.annotation is not None
276
287
  }
277
288
  if node.args.vararg is not None and node.args.vararg.annotation is not None:
278
- annotations[f"*{node.args.vararg.arg}"] = _normalise_type(ast.unparse(node.args.vararg.annotation))
289
+ annotations[f"*{node.args.vararg.arg}"] = _annotation_type(node.args.vararg.annotation)
279
290
  if node.args.kwarg is not None and node.args.kwarg.annotation is not None:
280
- annotations[f"**{node.args.kwarg.arg}"] = _normalise_type(ast.unparse(node.args.kwarg.annotation))
281
- return_type = _normalise_type(ast.unparse(node.returns)) if node.returns is not None else ""
282
- body_line_counts = _section_body_line_counts(doc)
291
+ annotations[f"**{node.args.kwarg.arg}"] = _annotation_type(node.args.kwarg.annotation)
292
+ return_type = _annotation_type(node.returns) if node.returns is not None else None
293
+ yield_type = _yielded_type(node.returns) if node.returns is not None else None
294
+ visible_doc = _without_literal_examples(doc)
295
+ body_line_counts = _section_body_line_counts(visible_doc)
283
296
 
284
297
  active: str | None = None
285
298
  active_header: int | None = None
286
299
  result: list[int] = []
287
- for index, raw in enumerate(doc.splitlines()):
300
+ for index, raw in enumerate(visible_doc.splitlines()):
288
301
  if heading := _SECTION_HEADING_RE.match(raw):
289
302
  active = heading.group(1)
290
303
  active_header = index
@@ -297,20 +310,63 @@ def _typed_restatement_lines(
297
310
  continue
298
311
  if active in {"Args", "Arguments", "Parameters", "Params", "Keyword Args", "Keyword Arguments"}:
299
312
  match = _ARG_TYPE_ENTRY_RE.match(raw) or _NUMPY_ARG_TYPE_ENTRY_RE.match(raw)
300
- if match is not None and _normalise_type(match.group(2)) == annotations.get(match.group(1)):
313
+ if match is not None and _documented_type(match.group(2)) == annotations.get(match.group(1)):
301
314
  result.append(docstring_line + index)
302
315
  elif match := _RETURN_TYPE_ENTRY_RE.match(raw):
303
- if _normalise_type(match.group(1)) == return_type:
316
+ expected = yield_type if active in {"Yields", "Yield"} else return_type
317
+ if expected is not None and _documented_type(match.group(1)) == expected:
304
318
  result.append(docstring_line + index)
305
319
  elif (
306
320
  active_header is not None
307
321
  and body_line_counts.get(active_header) == 1
308
- and _normalise_type(raw.strip()) == return_type
322
+ and (expected := yield_type if active in {"Yields", "Yield"} else return_type) is not None
323
+ and _documented_type(raw.strip()) == expected
309
324
  ):
310
325
  result.append(docstring_line + index)
311
326
  return tuple(result)
312
327
 
313
328
 
329
+ def _without_literal_examples(doc: str) -> str:
330
+ visible: list[str] = []
331
+ fence: str | None = None
332
+ literal_indent: int | None = None
333
+ awaiting_literal_block = False
334
+ for raw in doc.splitlines():
335
+ stripped = raw.lstrip()
336
+ indent = len(raw) - len(stripped)
337
+ fence_marker = stripped[:3] if stripped.startswith(("```", "~~~")) else None
338
+ if fence is not None:
339
+ visible.append("")
340
+ if fence_marker == fence:
341
+ fence = None
342
+ continue
343
+ if fence_marker is not None:
344
+ fence = fence_marker
345
+ visible.append("")
346
+ continue
347
+ if stripped.startswith((">>>", "...")):
348
+ visible.append("")
349
+ continue
350
+ if literal_indent is not None:
351
+ if not stripped or indent > literal_indent:
352
+ visible.append("")
353
+ continue
354
+ literal_indent = None
355
+ if awaiting_literal_block:
356
+ if not stripped:
357
+ visible.append(raw)
358
+ continue
359
+ if indent > 0:
360
+ literal_indent = indent - 1
361
+ visible.append("")
362
+ awaiting_literal_block = False
363
+ continue
364
+ awaiting_literal_block = False
365
+ visible.append(raw)
366
+ awaiting_literal_block = stripped.endswith("::")
367
+ return "\n".join(visible)
368
+
369
+
314
370
  def _section_body_line_counts(doc: str) -> dict[int, int]:
315
371
  counts: dict[int, int] = {}
316
372
  active_header: int | None = None
@@ -325,5 +381,59 @@ def _section_body_line_counts(doc: str) -> dict[int, int]:
325
381
  return counts
326
382
 
327
383
 
384
+ def _annotation_type(node: ast.expr) -> str:
385
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
386
+ parsed = _parse_type_expression(node.value)
387
+ return _normalise_type(ast.unparse(parsed)) if parsed is not None else node.value
388
+ return _normalise_type(ast.unparse(node))
389
+
390
+
391
+ def _documented_type(value: str) -> str | None:
392
+ parsed = _parse_type_expression(value)
393
+ return None if parsed is None else _normalise_type(ast.unparse(parsed))
394
+
395
+
396
+ def _parse_type_expression(value: str) -> ast.expr | None:
397
+ try:
398
+ parsed = ast.parse(value.strip(), mode="eval").body
399
+ except SyntaxError:
400
+ return None
401
+ unsafe = (
402
+ ast.Await,
403
+ ast.BoolOp,
404
+ ast.Call,
405
+ ast.Compare,
406
+ ast.DictComp,
407
+ ast.GeneratorExp,
408
+ ast.IfExp,
409
+ ast.Lambda,
410
+ ast.ListComp,
411
+ ast.NamedExpr,
412
+ ast.SetComp,
413
+ ast.Yield,
414
+ ast.YieldFrom,
415
+ )
416
+ if any(isinstance(part, unsafe) for part in ast.walk(parsed)):
417
+ return None
418
+ if any(isinstance(part, ast.BinOp) and not isinstance(part.op, ast.BitOr) for part in ast.walk(parsed)):
419
+ return None
420
+ return parsed
421
+
422
+
423
+ def _yielded_type(node: ast.expr) -> str | None:
424
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
425
+ parsed = _parse_type_expression(node.value)
426
+ if parsed is None:
427
+ return None
428
+ node = parsed
429
+ if not isinstance(node, ast.Subscript):
430
+ return None
431
+ name = node.value.attr if isinstance(node.value, ast.Attribute) else node.value.id if isinstance(node.value, ast.Name) else ""
432
+ if name not in {"AsyncGenerator", "AsyncIterable", "AsyncIterator", "Generator", "Iterable", "Iterator"}:
433
+ return None
434
+ members = node.slice.elts if isinstance(node.slice, ast.Tuple) else (node.slice,)
435
+ return _annotation_type(members[0]) if members else None
436
+
437
+
328
438
  def _normalise_type(value: str) -> str:
329
- return re.sub(r"\s+", "", value).removeprefix("typing.").removesuffix(",optional").casefold()
439
+ return re.sub(r"\s+", "", value).removeprefix("typing.").casefold()