sarj-python-lint 0.79.0__tar.gz → 0.80.0__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 (128) hide show
  1. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/PKG-INFO +2 -1
  2. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/pyproject.toml +2 -2
  3. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/_ratchet_cli.py +50 -2
  4. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/ratchet.py +95 -22
  5. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_registry.py +2 -0
  6. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +47 -3
  7. sarj_python_lint-0.80.0/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +294 -0
  8. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/.gitignore +0 -0
  9. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/LICENSE +0 -0
  10. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/README.md +0 -0
  11. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/__init__.py +0 -0
  12. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/__main__.py +0 -0
  13. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/_analysis_session.py +0 -0
  14. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/_filesystem.py +0 -0
  15. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/_secret_names.py +0 -0
  16. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/_version.py +0 -0
  17. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/py.typed +0 -0
  18. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rule_base.py +0 -0
  19. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  20. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  21. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  22. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  23. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  24. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  25. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  26. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  27. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  28. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
  29. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  30. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  31. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  32. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  33. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  34. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  35. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  36. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  37. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  38. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  39. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  40. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  41. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  42. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  43. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  44. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  45. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  46. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  47. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  48. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  49. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  50. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  51. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  52. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  53. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  54. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  55. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  56. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
  57. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  58. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  59. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  60. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  61. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  62. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  63. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  64. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  65. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  66. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  67. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  68. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  69. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  70. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  71. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  72. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  73. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  74. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  75. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  76. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  77. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  78. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  79. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  80. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  81. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  82. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  83. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  84. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  85. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  86. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  87. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  88. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  89. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  90. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  91. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_one_for_required_row.py +0 -0
  92. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  93. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  94. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  95. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  96. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  97. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  98. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  99. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  100. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  101. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  102. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/preserve_declared_nominal_id.py +0 -0
  103. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/preserve_enum_types.py +0 -0
  104. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  105. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  106. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  107. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  108. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  109. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  110. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  111. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  112. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  113. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  114. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  115. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  116. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  117. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/require_validated_row_factory.py +0 -0
  118. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  119. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/source_coupled_test.py +0 -0
  120. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/sql_requires_injected_pool_owner.py +0 -0
  121. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  122. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  123. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  124. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  125. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  126. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  127. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  128. {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/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.79.0
3
+ Version: 0.80.0
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/
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3
17
17
  Classifier: Programming Language :: Python :: 3.14
18
18
  Classifier: Topic :: Software Development :: Quality Assurance
19
19
  Requires-Python: >=3.14
20
+ Requires-Dist: ruff>=0.16.3
20
21
  Description-Content-Type: text/markdown
21
22
 
22
23
  <!-- 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.79.0"
3
+ version = "0.80.0"
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 = []
18
+ dependencies = ["ruff>=0.16.3"]
19
19
 
20
20
  [project.scripts]
21
21
  sarj-python-lint = "sarj_python_lint.__main__:main"
@@ -1,9 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import json
4
5
  from pathlib import Path
6
+ import subprocess # ruff: ignore[suspicious-subprocess-import] -- fixed local Ruff introspection only
5
7
  import sys
6
- from typing import TYPE_CHECKING
8
+ from typing import TYPE_CHECKING, TypeGuard
7
9
 
8
10
  from sarj_python_lint._filesystem import atomic_write_text
9
11
  from sarj_python_lint._version import __version__
@@ -93,7 +95,17 @@ def main(argv: list[str] | None = None) -> int:
93
95
  file_exceptions=baseline.file_exceptions,
94
96
  excluded_subtrees=excluded_subtrees,
95
97
  )
96
- measurement = measure(root, packages, excluded_subtrees=excluded_subtrees)
98
+ try:
99
+ ruff_aliases = _ruff_selector_aliases()
100
+ measurement = measure(
101
+ root,
102
+ packages,
103
+ excluded_subtrees=excluded_subtrees,
104
+ ruff_aliases=ruff_aliases,
105
+ )
106
+ except (OSError, ValueError, subprocess.SubprocessError) as exc:
107
+ sys.stderr.write(f"sarj-ratchet: cannot measure suppressions: {exc}\n")
108
+ return 2
97
109
 
98
110
  if args.update:
99
111
  # A first seed has nothing to raise: every ceiling is 0 only because no
@@ -124,6 +136,42 @@ def main(argv: list[str] | None = None) -> int:
124
136
  return 0
125
137
 
126
138
 
139
+ def _ruff_selector_aliases() -> dict[str, str]:
140
+ completed = subprocess.run(
141
+ [sys.executable, "-m", "ruff", "rule", "--all", "--output-format", "json"],
142
+ check=True,
143
+ capture_output=True,
144
+ text=True,
145
+ )
146
+ raw: object = json.loads(completed.stdout) # pyright: ignore[reportAny] -- validated below
147
+ if not _is_object_list(raw):
148
+ msg = "Ruff rule catalog was not a JSON list"
149
+ raise TypeError(msg)
150
+ aliases: dict[str, str] = {}
151
+ for raw_item in raw:
152
+ if not _is_string_object_mapping(raw_item):
153
+ msg = "Ruff rule catalog contains a non-object entry"
154
+ raise TypeError(msg)
155
+ code = raw_item.get("code")
156
+ name = raw_item.get("name")
157
+ if not isinstance(code, str) or not isinstance(name, str):
158
+ msg = "Ruff rule catalog entry lacks a string code or name"
159
+ raise TypeError(msg)
160
+ canonical = code.upper()
161
+ aliases[canonical] = canonical
162
+ aliases[name.lower()] = canonical
163
+ return aliases
164
+
165
+
166
+ def _is_object_list(value: object) -> TypeGuard[list[object]]:
167
+ return isinstance(value, list)
168
+
169
+
170
+ def _is_string_object_mapping(value: object) -> TypeGuard[dict[str, object]]:
171
+ # JSON object keys are strings by definition; json.loads supplied `value`.
172
+ return isinstance(value, dict)
173
+
174
+
127
175
  def _build_parser() -> argparse.ArgumentParser:
128
176
  parser = argparse.ArgumentParser(
129
177
  prog="sarj-ratchet",
@@ -2,8 +2,10 @@ from __future__ import annotations
2
2
 
3
3
  from collections import Counter
4
4
  from dataclasses import dataclass, field
5
+ from io import StringIO
5
6
  import json
6
7
  import re
8
+ import tokenize
7
9
  from types import MappingProxyType
8
10
  from typing import TYPE_CHECKING, Final, NamedTuple, TypeGuard
9
11
 
@@ -37,18 +39,28 @@ class Improvement(NamedTuple):
37
39
 
38
40
 
39
41
  DEFAULT_PER_FILE_CEILING: Final = 10
40
-
41
- _NOQA_RE: Final = re.compile(r"#\s*noqa:\s*([A-Z][A-Z0-9]+(?:\s*,\s*[A-Z][A-Z0-9]+)*)")
42
+ BASELINE_SCHEMA_VERSION: Final = 1
43
+
44
+ _RUFF_SELECTOR: Final = r"[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*"
45
+ _RUFF_SELECTOR_LIST: Final = rf"({_RUFF_SELECTOR}(?:\s*,\s*{_RUFF_SELECTOR})*)"
46
+ _NOQA_RE: Final = re.compile(rf"#\s*noqa(?::\s*{_RUFF_SELECTOR_LIST})?", re.IGNORECASE)
47
+ _RUFF_IGNORE_RE: Final = re.compile(rf"#\s*ruff:\s*ignore\s*\[{_RUFF_SELECTOR_LIST}(?:\s*,)?\s*\]", re.IGNORECASE)
48
+ _RUFF_FILE_IGNORE_RE: Final = re.compile(
49
+ rf"^#\s*ruff:\s*file-ignore\s*\[{_RUFF_SELECTOR_LIST}(?:\s*,)?\s*\]", re.IGNORECASE
50
+ )
51
+ _RUFF_DISABLE_RE: Final = re.compile(rf"^#\s*ruff:\s*disable\s*\[{_RUFF_SELECTOR_LIST}(?:\s*,)?\s*\]", re.IGNORECASE)
42
52
  _SARJ_NOQA_RE: Final = re.compile(r"#\s*sarj-noqa:\s*(SARJ\d+(?:\s*,\s*SARJ\d+)*)", re.IGNORECASE)
43
53
  _PYRIGHT_IGNORE_RE: Final = re.compile(r"#\s*pyright:\s*ignore\[([^\]]+)\]")
44
54
  _TYPE_IGNORE_RE: Final = re.compile(r"#\s*type:\s*ignore(?:\[([^\]]+)\])?")
45
- _FILE_NOQA_RE: Final = re.compile(
46
- r"^\s*#\s*ruff:\s*noqa(?::\s*([A-Z][A-Z0-9]+(?:\s*,\s*[A-Z][A-Z0-9]+)*))?", re.IGNORECASE
47
- )
48
- _FILE_PYRIGHT_RE: Final = re.compile(r"^\s*#\s*pyright:\s*(?!ignore\b)")
55
+ _FILE_NOQA_RE: Final = re.compile(rf"^#\s*ruff:\s*noqa(?::\s*{_RUFF_SELECTOR_LIST})?", re.IGNORECASE)
56
+ _FLAKE8_FILE_NOQA_RE: Final = re.compile(r"^#\s*flake8:\s*noqa\b", re.IGNORECASE)
57
+ _FILE_PYRIGHT_RE: Final = re.compile(r"^#\s*pyright:(?!\s*ignore\b)\s*")
49
58
  _FILE_PYRIGHT_RULE_RE: Final = re.compile(r"([A-Za-z]\w*)\s*=\s*false")
50
59
 
51
60
  _BLANKET_KEY: Final = "file-noqa:<blanket>"
61
+ _INLINE_BLANKET_KEY: Final = "noqa:<blanket>"
62
+ _FLAKE8_BLANKET_KEY: Final = "file-noqa:<blanket>"
63
+ _BARE_PYRIGHT_IGNORE_KEY: Final = "pyright:<blanket>"
52
64
  _BARE_TYPE_IGNORE_KEY: Final = "type-ignore"
53
65
 
54
66
 
@@ -111,6 +123,7 @@ def measure(
111
123
  *,
112
124
  excluded_dir_names: frozenset[str] = DEFAULT_EXCLUDED_DIR_NAMES,
113
125
  excluded_subtrees: Iterable[str] = (),
126
+ ruff_aliases: Mapping[str, str] | None = None,
114
127
  ) -> Measurement:
115
128
  codes: Counter[str] = Counter()
116
129
  package_counts: Counter[str] = Counter()
@@ -122,7 +135,7 @@ def measure(
122
135
  relative = path.relative_to(root).as_posix()
123
136
  if any(_inside_subtree(relative, subtree) for subtree in excluded):
124
137
  continue
125
- found = count_source(_read(path))
138
+ found = count_source(_read(path), ruff_aliases=ruff_aliases)
126
139
  if not found:
127
140
  continue
128
141
  codes.update(found)
@@ -132,10 +145,15 @@ def measure(
132
145
  return Measurement(codes=codes, packages=package_counts, files=files)
133
146
 
134
147
 
135
- def count_source(source: str) -> Counter[str]:
148
+ def count_source(source: str, *, ruff_aliases: Mapping[str, str] | None = None) -> Counter[str]:
136
149
  counts: Counter[str] = Counter()
137
- for line in source.splitlines():
138
- _count_line(line, counts)
150
+ lines = source.splitlines()
151
+ for token in tokenize.generate_tokens(StringIO(source).readline):
152
+ if token.type != tokenize.COMMENT:
153
+ continue
154
+ line = lines[token.start[0] - 1] if token.start[0] <= len(lines) else ""
155
+ standalone = not line[: token.start[1]].strip()
156
+ _count_comment(token.string, counts, standalone=standalone, ruff_aliases=ruff_aliases)
139
157
  return counts
140
158
 
141
159
 
@@ -183,6 +201,10 @@ def load_baseline(path: Path) -> Baseline:
183
201
  raw: object = json.loads( # pyright: ignore[reportAny] — json.loads is an untyped stdlib boundary; every read below narrows
184
202
  path.read_text(encoding="utf-8")
185
203
  )
204
+ schema = _get(raw, "schema_version")
205
+ if schema is not None and schema != BASELINE_SCHEMA_VERSION:
206
+ msg = f"unsupported suppression baseline schema_version: {schema!r}"
207
+ raise ValueError(msg)
186
208
  files = _get(raw, "files")
187
209
  ceiling = _get(files, "per_file_ceiling")
188
210
  return Baseline(
@@ -204,6 +226,7 @@ def _get(mapping: object, key: str) -> object:
204
226
 
205
227
  def dump_baseline(baseline: Baseline, packages: Iterable[str]) -> str:
206
228
  payload = {
229
+ "schema_version": BASELINE_SCHEMA_VERSION,
207
230
  "_comment": (
208
231
  "Suppression ceilings, written by `sarj-ratchet --update`. Counts may "
209
232
  "only go DOWN: `codes` is per dialect+code, `packages` is per "
@@ -283,27 +306,77 @@ def _read(path: Path) -> str:
283
306
 
284
307
 
285
308
  def _count_line(line: str, counts: Counter[str]) -> None:
286
- if file_noqa := _FILE_NOQA_RE.match(line):
309
+ for token in tokenize.generate_tokens(StringIO(line).readline):
310
+ if token.type == tokenize.COMMENT:
311
+ _count_comment(
312
+ token.string,
313
+ counts,
314
+ standalone=not line[: token.start[1]].strip(),
315
+ ruff_aliases=None,
316
+ )
317
+
318
+
319
+ def _count_comment(
320
+ comment: str,
321
+ counts: Counter[str],
322
+ *,
323
+ standalone: bool,
324
+ ruff_aliases: Mapping[str, str] | None,
325
+ ) -> None:
326
+ if standalone and (file_ignore := _RUFF_FILE_IGNORE_RE.match(comment)):
327
+ counts.update(
328
+ f"file-noqa:{_normalized_ruff_selector(code, ruff_aliases)}" for code in file_ignore.group(1).split(",")
329
+ )
330
+ return
331
+ if standalone and (file_noqa := _FILE_NOQA_RE.match(comment)):
287
332
  listed = file_noqa.group(1)
288
333
  if listed:
289
- counts.update(f"file-noqa:{code.strip().upper()}" for code in listed.split(","))
334
+ counts.update(f"file-noqa:{_normalized_ruff_selector(code, ruff_aliases)}" for code in listed.split(","))
290
335
  else:
291
336
  counts[_BLANKET_KEY] += 1
292
337
  return
293
- if _FILE_PYRIGHT_RE.match(line):
294
- rules: list[str] = _FILE_PYRIGHT_RULE_RE.findall(line)
338
+ if standalone and _FLAKE8_FILE_NOQA_RE.match(comment):
339
+ counts[_FLAKE8_BLANKET_KEY] += 1
340
+ return
341
+ if standalone and (disabled := _RUFF_DISABLE_RE.match(comment)):
342
+ counts.update(
343
+ f"ruff-range:{_normalized_ruff_selector(code, ruff_aliases)}" for code in disabled.group(1).split(",")
344
+ )
345
+ return
346
+ if standalone and _FILE_PYRIGHT_RE.match(comment):
347
+ rules: list[str] = _FILE_PYRIGHT_RULE_RE.findall(comment)
295
348
  counts.update(f"file-pyright:{rule}" for rule in rules)
296
349
  return
297
- for pattern, prefix in (
298
- (_NOQA_RE, "noqa:"),
299
- (_SARJ_NOQA_RE, "sarj-noqa:"),
300
- (_PYRIGHT_IGNORE_RE, "pyright:"),
301
- ):
302
- for match in pattern.finditer(line):
303
- counts.update(f"{prefix}{code.strip()}" for code in match.group(1).split(","))
304
- for match in _TYPE_IGNORE_RE.finditer(line):
350
+ for match in _NOQA_RE.finditer(comment):
351
+ listed = match.group(1)
352
+ if listed:
353
+ counts.update(f"noqa:{_normalized_ruff_selector(code, ruff_aliases)}" for code in listed.split(","))
354
+ else:
355
+ counts[_INLINE_BLANKET_KEY] += 1
356
+ for match in _RUFF_IGNORE_RE.finditer(comment):
357
+ prefix = "standalone-noqa:" if standalone else "noqa:"
358
+ counts.update(f"{prefix}{_normalized_ruff_selector(code, ruff_aliases)}" for code in match.group(1).split(","))
359
+ for match in _SARJ_NOQA_RE.finditer(comment):
360
+ counts.update(f"sarj-noqa:{code.strip().upper()}" for code in match.group(1).split(","))
361
+ for match in _PYRIGHT_IGNORE_RE.finditer(comment):
362
+ counts.update(f"pyright:{code.strip()}" for code in match.group(1).split(","))
363
+ if re.search(r"#\s*pyright:\s*ignore\b(?!\s*\[)", comment):
364
+ counts[_BARE_PYRIGHT_IGNORE_KEY] += 1
365
+ for match in _TYPE_IGNORE_RE.finditer(comment):
305
366
  listed = match.group(1)
306
367
  if listed:
307
368
  counts.update(f"type-ignore:{code.strip()}" for code in listed.split(","))
308
369
  else:
309
370
  counts[_BARE_TYPE_IGNORE_KEY] += 1
371
+
372
+
373
+ def _normalized_ruff_selector(selector: str, aliases: Mapping[str, str] | None) -> str:
374
+ stripped = selector.strip()
375
+ normalized = stripped.upper() if stripped.isupper() else stripped.lower()
376
+ if aliases is None:
377
+ return normalized
378
+ try:
379
+ return aliases[normalized]
380
+ except KeyError as exc:
381
+ msg = f"unknown Ruff suppression selector: {stripped}"
382
+ raise ValueError(msg) from exc
@@ -111,6 +111,7 @@ from sarj_python_lint.rules.prefer_struct_over_namedtuple import (
111
111
  from sarj_python_lint.rules.prefer_timedelta_for_durations import (
112
112
  PreferTimedeltaForDurations,
113
113
  )
114
+ from sarj_python_lint.rules.prefer_walrus_awaited_none_guard import PreferWalrusAwaitedNoneGuard
114
115
  from sarj_python_lint.rules.prefer_walrus_comprehension_filter import (
115
116
  PreferWalrusComprehensionFilter,
116
117
  )
@@ -162,6 +163,7 @@ if TYPE_CHECKING:
162
163
 
163
164
  REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
164
165
  {
166
+ PreferWalrusAwaitedNoneGuard.id: PreferWalrusAwaitedNoneGuard,
165
167
  TimestampOrderRequiresTiebreaker.id: TimestampOrderRequiresTiebreaker,
166
168
  NoStringConcatInLoop.id: NoStringConcatInLoop,
167
169
  PreferClassRow.id: PreferClassRow,
@@ -15,7 +15,7 @@ from sarj_python_lint.rule_base import (
15
15
  RuleExample,
16
16
  parse_or_none,
17
17
  )
18
- from sarj_python_lint.rules._paths import is_generated, is_test_path
18
+ from sarj_python_lint.rules._paths import is_generated, is_test_path, is_test_support_path
19
19
 
20
20
 
21
21
  if TYPE_CHECKING:
@@ -42,8 +42,9 @@ class NoRawConnectionInTests(Rule):
42
42
  category=RuleCategory.TESTING,
43
43
  autofix=AutofixPolicy.NONE,
44
44
  limitations=(
45
- "Only test paths are inspected.",
45
+ "Only test paths outside conftest.py and conventional shared test-support modules are inspected.",
46
46
  "A receiver is reported only when a parameter, annotated local, or constructor call proves it is a psycopg ConnectionPool or AsyncConnectionPool.",
47
+ "Pytest fixtures may use a connection internally for setup and cleanup, but fixtures that return or yield the connection remain reportable.",
47
48
  ),
48
49
  examples=(
49
50
  RuleExample(
@@ -80,7 +81,12 @@ class NoRawConnectionInTests(Rule):
80
81
 
81
82
  @override
82
83
  def check(self, path: Path, source: str) -> list[Diagnostic]:
83
- if not is_test_path(path) or is_generated(path, source):
84
+ if (
85
+ not is_test_path(path)
86
+ or path.name == "conftest.py"
87
+ or is_test_support_path(path)
88
+ or is_generated(path, source)
89
+ ):
84
90
  return []
85
91
  tree = parse_or_none(path, source)
86
92
  if tree is None:
@@ -109,6 +115,7 @@ class NoRawConnectionInTests(Rule):
109
115
  and node.func.attr == "connection"
110
116
  and isinstance(node.func.value, ast.Name)
111
117
  and node.func.value.id in pool_names
118
+ and not _is_internal_fixture_connection(scope, node)
112
119
  )
113
120
  return sorted(diagnostics, key=lambda item: (item.line, item.col))
114
121
 
@@ -152,3 +159,40 @@ def _tail(node: ast.expr | None) -> str:
152
159
  return _tail(value)
153
160
  case _:
154
161
  return ""
162
+
163
+
164
+ def _is_internal_fixture_connection(
165
+ scope: ast.Module | ast.FunctionDef | ast.AsyncFunctionDef, connection_call: ast.Call
166
+ ) -> bool:
167
+ if not isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef)) or not _is_pytest_fixture(scope):
168
+ return False
169
+ context_item = next(
170
+ (
171
+ item
172
+ for node in _scope_nodes(scope)
173
+ if isinstance(node, (ast.With, ast.AsyncWith))
174
+ for item in node.items
175
+ if item.context_expr is connection_call
176
+ ),
177
+ None,
178
+ )
179
+ if context_item is None:
180
+ return False
181
+ if context_item.optional_vars is None:
182
+ return True
183
+ if not isinstance(context_item.optional_vars, ast.Name):
184
+ return False
185
+ bound_name = context_item.optional_vars.id
186
+ return not any(
187
+ isinstance(node, (ast.Return, ast.Yield, ast.YieldFrom))
188
+ and node.value is not None
189
+ and any(isinstance(value, ast.Name) and value.id == bound_name for value in ast.walk(node.value))
190
+ for node in _scope_nodes(scope)
191
+ )
192
+
193
+
194
+ def _is_pytest_fixture(scope: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
195
+ return any(
196
+ _tail(decorator.func if isinstance(decorator, ast.Call) else decorator) == "fixture"
197
+ for decorator in scope.decorator_list
198
+ )
@@ -0,0 +1,294 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from io import StringIO
5
+ from itertools import pairwise
6
+ from pathlib import PurePosixPath
7
+ import tokenize
8
+ from typing import TYPE_CHECKING, ClassVar, NamedTuple, final, override
9
+
10
+ from sarj_python_lint.rule_base import (
11
+ AutofixPolicy,
12
+ Diagnostic,
13
+ ExampleFile,
14
+ ExampleOutcome,
15
+ Rule,
16
+ RuleCategory,
17
+ RuleDocumentation,
18
+ RuleExample,
19
+ Severity,
20
+ is_suppressed,
21
+ parse_or_none,
22
+ )
23
+ from sarj_python_lint.rules._paths import is_generated
24
+
25
+
26
+ if TYPE_CHECKING:
27
+ from pathlib import Path
28
+
29
+
30
+ _MAX_COMBINED_LINE_LENGTH = 120
31
+
32
+
33
+ class _WalrusCandidate(NamedTuple):
34
+ name: str
35
+ awaited: ast.Await
36
+
37
+
38
+ @final
39
+ class PreferWalrusAwaitedNoneGuard(Rule):
40
+ id = "prefer-walrus-awaited-none-guard"
41
+ code = "SARJ432"
42
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
43
+ summary="Bind a compact awaited lookup in its immediately following None guard.",
44
+ rationale=(
45
+ "When an awaited lookup and its mandatory absence guard are adjacent, binding in the condition keeps "
46
+ "the operation and the reason for the temporary name in one compact expression."
47
+ ),
48
+ remediation=(
49
+ "Rewrite `value = await lookup(); if value is None: return` as "
50
+ "`if (value := await lookup()) is None: return`."
51
+ ),
52
+ category=RuleCategory.STYLE,
53
+ autofix=AutofixPolicy.NONE,
54
+ limitations=(
55
+ "Only one-line awaited calls followed immediately by an exact `is None` guard containing one return are checked.",
56
+ "The combined condition must fit 120 columns; comments, else branches, rebinding, generated files, and broader assignment-expression preferences are excluded.",
57
+ ),
58
+ examples=(
59
+ RuleExample(
60
+ example_id="awaited-optional-lookup",
61
+ title="An awaited lookup is split from its absence guard",
62
+ outcome=ExampleOutcome.MATCH,
63
+ files=(
64
+ ExampleFile.python(
65
+ "service.py",
66
+ "async def load(store, item_id):\n"
67
+ " item = await store.get(item_id)\n"
68
+ " if item is None:\n"
69
+ " return\n"
70
+ " return item.name\n",
71
+ ),
72
+ ),
73
+ focus_path=PurePosixPath("service.py"),
74
+ expected_count=1,
75
+ public=True,
76
+ ),
77
+ RuleExample(
78
+ example_id="guard-bound-lookup",
79
+ title="The awaited lookup is bound in its absence guard",
80
+ outcome=ExampleOutcome.NO_MATCH,
81
+ files=(
82
+ ExampleFile.python(
83
+ "service.py",
84
+ "async def load(store, item_id):\n"
85
+ " if (item := await store.get(item_id)) is None:\n"
86
+ " return\n"
87
+ " return item.name\n",
88
+ ),
89
+ ),
90
+ focus_path=PurePosixPath("service.py"),
91
+ expected_count=0,
92
+ public=True,
93
+ ),
94
+ ),
95
+ )
96
+ description = documentation.summary
97
+
98
+ @override
99
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
100
+ if path.suffix == ".pyi" or is_generated(path, source):
101
+ return []
102
+ tree = parse_or_none(path, source)
103
+ if tree is None:
104
+ return []
105
+ source_lines = source.splitlines()
106
+ comment_lines = _comment_lines(source)
107
+ diagnostics: list[Diagnostic] = []
108
+ for body in _statement_lists(tree):
109
+ for index, (assignment, guard) in enumerate(pairwise(body)):
110
+ candidate = _candidate(assignment, guard, source, source_lines, comment_lines)
111
+ if candidate is None:
112
+ continue
113
+ name, awaited = candidate
114
+ if not _used_before_rebinding(body[index + 2 :], name):
115
+ continue
116
+ if is_suppressed(source_lines, assignment.lineno, self.code) or is_suppressed(
117
+ source_lines, guard.lineno, self.code
118
+ ):
119
+ continue
120
+ expression = ast.get_source_segment(source, awaited)
121
+ if expression is None:
122
+ continue
123
+ diagnostics.append(
124
+ Diagnostic(
125
+ path=path,
126
+ line=assignment.lineno,
127
+ col=assignment.col_offset + 1,
128
+ code=self.code,
129
+ severity=Severity.WARNING,
130
+ message=(
131
+ f"`{name}` only separates an awaited lookup from its None guard; "
132
+ f"bind it as `if ({name} := {expression.strip()}) is None:`"
133
+ ),
134
+ )
135
+ )
136
+ return sorted(diagnostics, key=lambda item: (item.line, item.col))
137
+
138
+
139
+ def _candidate(
140
+ assignment: ast.stmt,
141
+ guard: ast.stmt,
142
+ source: str,
143
+ source_lines: list[str],
144
+ comment_lines: frozenset[int],
145
+ ) -> _WalrusCandidate | None:
146
+ if not (
147
+ isinstance(assignment, ast.Assign)
148
+ and len(assignment.targets) == 1
149
+ and isinstance(assignment.targets[0], ast.Name)
150
+ and isinstance(assignment.value, ast.Await)
151
+ and isinstance(assignment.value.value, ast.Call)
152
+ and assignment.end_lineno is not None
153
+ and assignment.lineno == assignment.end_lineno
154
+ and isinstance(guard, ast.If)
155
+ and guard.lineno == assignment.end_lineno + 1
156
+ and guard.col_offset == assignment.col_offset
157
+ and guard.test.end_lineno == guard.lineno
158
+ and not guard.orelse
159
+ and len(guard.body) == 1
160
+ and isinstance(guard.body[0], ast.Return)
161
+ ):
162
+ return None
163
+ name = assignment.targets[0].id
164
+ if not _is_none_guard(guard.test, name) or _loads_name(assignment.value, name) or _loads_name(guard.body[0], name):
165
+ return None
166
+ if comment_lines.intersection(range(assignment.lineno, guard.lineno + 1)):
167
+ return None
168
+ expression = ast.get_source_segment(source, assignment.value)
169
+ if expression is None:
170
+ return None
171
+ original_line = source_lines[assignment.lineno - 1]
172
+ indentation = original_line[: len(original_line) - len(original_line.lstrip())].expandtabs(8)
173
+ combined = f"{indentation}if ({name} := {expression.strip()}) is None:"
174
+ if len(combined) > _MAX_COMBINED_LINE_LENGTH:
175
+ return None
176
+ return _WalrusCandidate(name, assignment.value)
177
+
178
+
179
+ def _is_none_guard(test: ast.expr, name: str) -> bool:
180
+ return (
181
+ isinstance(test, ast.Compare)
182
+ and isinstance(test.left, ast.Name)
183
+ and test.left.id == name
184
+ and len(test.ops) == 1
185
+ and isinstance(test.ops[0], ast.Is)
186
+ and len(test.comparators) == 1
187
+ and isinstance(test.comparators[0], ast.Constant)
188
+ and test.comparators[0].value is None
189
+ )
190
+
191
+
192
+ def _loads_name(node: ast.AST, name: str) -> bool:
193
+ return any(
194
+ isinstance(child, ast.Name) and isinstance(child.ctx, ast.Load) and child.id == name for child in ast.walk(node)
195
+ )
196
+
197
+
198
+ def _used_before_rebinding(statements: list[ast.stmt], name: str) -> bool:
199
+ for statement in statements:
200
+ usage = _NameUsage(name)
201
+ usage.visit(statement)
202
+ if usage.rebound:
203
+ return False
204
+ if usage.loaded:
205
+ return True
206
+ return False
207
+
208
+
209
+ @final
210
+ class _NameUsage(ast.NodeVisitor):
211
+ def __init__(self, name: str) -> None:
212
+ self.name = name
213
+ self.loaded = False
214
+ self.rebound = False
215
+
216
+ def visit_Name(self, node: ast.Name) -> None:
217
+ if node.id != self.name:
218
+ return
219
+ if isinstance(node.ctx, ast.Load):
220
+ self.loaded = True
221
+ elif isinstance(node.ctx, (ast.Store, ast.Del)):
222
+ self.rebound = True
223
+
224
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
225
+ self.rebound |= node.name == self.name
226
+
227
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
228
+ self.rebound |= node.name == self.name
229
+
230
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
231
+ self.rebound |= node.name == self.name
232
+
233
+ @override
234
+ def visit_Lambda(self, node: ast.Lambda) -> None:
235
+ _ = node
236
+
237
+ def visit_Import(self, node: ast.Import) -> None:
238
+ self.rebound |= any((alias.asname or alias.name.split(".", maxsplit=1)[0]) == self.name for alias in node.names)
239
+
240
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
241
+ self.rebound |= any((alias.asname or alias.name) == self.name for alias in node.names)
242
+
243
+ def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
244
+ if node.name == self.name:
245
+ self.rebound = True
246
+ return
247
+ self.generic_visit(node)
248
+
249
+ def visit_MatchAs(self, node: ast.MatchAs) -> None:
250
+ if node.name == self.name:
251
+ self.rebound = True
252
+ return
253
+ self.generic_visit(node)
254
+
255
+ def visit_MatchStar(self, node: ast.MatchStar) -> None:
256
+ self.rebound |= node.name == self.name
257
+
258
+ def visit_MatchMapping(self, node: ast.MatchMapping) -> None:
259
+ if node.rest == self.name:
260
+ self.rebound = True
261
+ return
262
+ self.generic_visit(node)
263
+
264
+ def visit_Global(self, node: ast.Global) -> None:
265
+ self.rebound |= self.name in node.names
266
+
267
+ def visit_Nonlocal(self, node: ast.Nonlocal) -> None:
268
+ self.rebound |= self.name in node.names
269
+
270
+
271
+ def _statement_lists(tree: ast.AST) -> list[list[ast.stmt]]:
272
+ result: list[list[ast.stmt]] = []
273
+ for node in ast.walk(tree):
274
+ for field in ("body", "orelse", "finalbody"):
275
+ value = getattr(node, field, None)
276
+ if not isinstance(value, list):
277
+ continue
278
+ statements: list[ast.stmt] = []
279
+ for item in value: # pyright: ignore[reportUnknownVariableType] -- AST fields are untyped lists
280
+ if not isinstance(item, ast.stmt):
281
+ break
282
+ statements.append(item)
283
+ else:
284
+ if statements:
285
+ result.append(statements)
286
+ return result
287
+
288
+
289
+ def _comment_lines(source: str) -> frozenset[int]:
290
+ try:
291
+ tokens = tokenize.generate_tokens(StringIO(source).readline)
292
+ return frozenset(token.start[0] for token in tokens if token.type == tokenize.COMMENT)
293
+ except IndentationError, tokenize.TokenError:
294
+ return frozenset()