sarj-python-lint 0.80.15__tar.gz → 0.81.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 (125) hide show
  1. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/PKG-INFO +3 -1
  2. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/pyproject.toml +2 -2
  3. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/__main__.py +78 -71
  4. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/_analysis_session.py +2 -0
  5. sarj_python_lint-0.81.0/src/sarj_python_lint/_python_target.py +141 -0
  6. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/_ratchet_cli.py +67 -55
  7. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rule_base.py +6 -0
  8. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_registry.py +2 -0
  9. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_comment_cruft.py +24 -0
  10. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +11 -9
  11. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +3 -0
  12. sarj_python_lint-0.81.0/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +394 -0
  13. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_str_enum.py +7 -6
  14. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/.gitignore +0 -0
  15. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/LICENSE +0 -0
  16. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/README.md +0 -0
  17. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/__init__.py +0 -0
  18. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/_filesystem.py +0 -0
  19. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/_secret_names.py +0 -0
  20. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/_version.py +0 -0
  21. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/py.typed +0 -0
  22. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/ratchet.py +0 -0
  23. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  24. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  25. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  26. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  27. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  28. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  29. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  30. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  31. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  32. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  33. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
  34. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  35. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  36. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  37. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  38. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  39. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  40. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  41. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  42. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  43. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  44. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  45. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  46. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  47. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  48. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  49. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  50. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  51. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  52. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  53. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  54. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  55. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  56. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  57. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  58. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  59. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  60. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  61. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  62. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  63. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  64. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  65. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  66. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  67. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  68. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  69. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  70. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  71. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  72. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  73. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  74. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  75. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  76. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  77. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  78. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  79. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  80. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  81. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  82. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  83. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  84. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  85. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  86. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  87. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  88. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  89. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  90. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  91. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  92. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  93. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  94. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  95. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  96. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  97. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  98. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  99. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  100. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  101. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  102. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  103. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  104. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  105. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  106. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  107. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  108. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  109. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  110. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  111. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  112. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  113. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  114. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  115. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  116. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  117. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  118. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  119. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  120. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  121. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  122. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  123. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  124. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  125. {sarj_python_lint-0.80.15 → sarj_python_lint-0.81.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.80.15
3
+ Version: 0.81.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,8 +17,10 @@ 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: packaging==26.3
20
21
  Requires-Dist: ruff>=0.16.5
21
22
  Requires-Dist: sqlglot==30.18.0
23
+ Requires-Dist: typer==0.27.2
22
24
  Description-Content-Type: text/markdown
23
25
 
24
26
  <!-- 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.15"
3
+ version = "0.81.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 = ["ruff>=0.16.5", "sqlglot==30.18.0"]
18
+ dependencies = ["packaging==26.3", "ruff>=0.16.5", "sqlglot==30.18.0", "typer==0.27.2"]
19
19
 
20
20
  [project.scripts]
21
21
  sarj-python-lint = "sarj_python_lint.__main__:main"
@@ -1,13 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
- import argparse
4
3
  import ast
5
4
  from collections import Counter
5
+ from dataclasses import dataclass
6
6
  import json
7
7
  from pathlib import Path
8
8
  import sys
9
9
  from types import MappingProxyType
10
- from typing import NamedTuple
10
+ from typing import Annotated, NamedTuple
11
+
12
+ import typer
11
13
 
12
14
  from sarj_python_lint import __version__
13
15
  from sarj_python_lint._analysis_session import AnalysisSession
@@ -236,24 +238,9 @@ def _docstring_owner_locations(source: str) -> dict[int, _OwnerLocation]:
236
238
  return owners
237
239
 
238
240
 
239
- class _Args(argparse.Namespace):
240
- cmd: str | None
241
- rule: list[str]
242
- # `explain` takes exactly one rule, so it cannot share `--rule`'s list slot
243
- # without widening the type and losing the check on every `check` call site.
244
- which: str
245
- files: list[Path]
246
- baseline: Path | None
247
- update_baseline: Path | None
248
-
249
- def __init__(self) -> None:
250
- super().__init__()
251
- self.cmd = None
252
- self.rule = []
253
- self.which = ""
254
- self.files = []
255
- self.baseline = None
256
- self.update_baseline = None
241
+ @dataclass
242
+ class _Result:
243
+ code: int = 0
257
244
 
258
245
 
259
246
  def _explain(wanted: str) -> int:
@@ -328,78 +315,98 @@ def _apply_baseline(
328
315
  return out
329
316
 
330
317
 
331
- def main(argv: list[str] | None = None) -> int:
332
- parser = argparse.ArgumentParser(
333
- prog="sarj-python-lint",
334
- description="Custom Python lint rules.",
335
- )
336
- parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
337
- sub = parser.add_subparsers(dest="cmd", required=True)
338
-
339
- check_p = sub.add_parser("check", help="Run rules over files.")
340
- check_p.add_argument(
341
- "--rule",
342
- action="append",
343
- required=True,
344
- help="Rule ID (repeat for multiple).",
345
- )
346
- check_p.add_argument(
347
- "--baseline",
348
- type=Path,
349
- help="Per-file shrink-only baseline JSON: {path: {CODE: count}}. Diags up to the baselined count are suppressed.",
350
- )
351
- check_p.add_argument(
352
- "--update-baseline",
353
- type=Path,
354
- help="Write the current per-file diagnostic counts to this JSON and exit 0.",
355
- )
356
- check_p.add_argument("files", nargs="+", type=Path)
357
-
358
- sub.add_parser("list-rules", help="List available rule IDs.")
359
-
360
- explain_p = sub.add_parser("explain", help="Print one rule's summary and the links to its examples and evidence.")
361
- explain_p.add_argument("which", metavar="rule", help="Rule ID or SARJ code.")
362
-
363
- args = parser.parse_args(argv, namespace=_Args())
364
-
365
- if args.cmd == "list-rules":
366
- for rid, cls in sorted(REGISTRY.items()):
367
- inst = cls()
368
- sys.stdout.write(f"{inst.code:8} {rid:40} {inst.description}\n")
369
- return 0
370
-
371
- if args.cmd == "explain":
372
- return _explain(args.which)
373
-
318
+ def _run_check(rule: list[str], files: list[Path], baseline: Path | None, update_baseline: Path | None) -> int:
374
319
  try:
375
- diags = analyze(args.rule, args.files)
320
+ diags = analyze(rule, files)
376
321
  except (OSError, ValueError) as exc:
377
322
  sys.stderr.write(f"error: {exc}\n")
378
323
  return 2
379
- if args.update_baseline is not None:
324
+ if update_baseline is not None:
380
325
  counts = _baseline_counts(diags)
381
326
  blocking = sum(d.severity is Severity.ERROR for d in diags)
382
327
  warnings = len(diags) - blocking
383
328
  try:
384
- atomic_write_text(args.update_baseline, json.dumps(counts, indent=2, sort_keys=True) + "\n")
329
+ atomic_write_text(update_baseline, json.dumps(counts, indent=2, sort_keys=True) + "\n")
385
330
  except OSError as exc:
386
- sys.stderr.write(f"error: cannot write baseline {args.update_baseline}: {exc}\n")
331
+ sys.stderr.write(f"error: cannot write baseline {update_baseline}: {exc}\n")
387
332
  return 2
388
333
  sys.stdout.write(
389
- f"baseline written: {args.update_baseline} "
334
+ f"baseline written: {update_baseline} "
390
335
  f"({blocking} blocking diagnostics over {len(counts)} files; {warnings} warnings excluded)\n"
391
336
  )
392
337
  return 0
393
- if args.baseline is not None:
338
+ if baseline is not None:
394
339
  try:
395
- diags = _apply_baseline(diags, _read_baseline(args.baseline))
340
+ diags = _apply_baseline(diags, _read_baseline(baseline))
396
341
  except (OSError, ValueError) as exc:
397
- sys.stderr.write(f"error: invalid baseline {args.baseline}: {exc}\n")
342
+ sys.stderr.write(f"error: invalid baseline {baseline}: {exc}\n")
398
343
  return 2
399
344
  for d in diags:
400
345
  sys.stdout.write(d.format() + "\n")
401
346
  return 1 if any(d.severity is Severity.ERROR for d in diags) else 0
402
347
 
403
348
 
349
+ app = typer.Typer(
350
+ help="Custom Python lint rules.",
351
+ add_completion=False,
352
+ no_args_is_help=False,
353
+ pretty_exceptions_enable=False,
354
+ context_settings={"help_option_names": ["-h", "--help"]},
355
+ )
356
+
357
+
358
+ def _show_version(*, value: bool) -> None:
359
+ if value:
360
+ sys.stdout.write(f"sarj-python-lint {__version__}\n")
361
+ raise typer.Exit
362
+
363
+
364
+ @app.callback()
365
+ def _root(
366
+ *,
367
+ _version: Annotated[bool, typer.Option("--version", callback=_show_version, is_eager=True)] = False,
368
+ ) -> None:
369
+ pass
370
+
371
+
372
+ @app.command("list-rules", help="List available rule IDs.")
373
+ def _list_rules() -> None:
374
+ for rid, cls in sorted(REGISTRY.items()):
375
+ inst = cls()
376
+ sys.stdout.write(f"{inst.code:8} {rid:40} {inst.description}\n")
377
+
378
+
379
+ @app.command("explain", help="Print one rule's summary and the links to its examples and evidence.")
380
+ def _explain_command(
381
+ context: typer.Context, which: Annotated[str, typer.Argument(metavar="rule", help="Rule ID or SARJ code.")]
382
+ ) -> None:
383
+ context.ensure_object(_Result).code = _explain(which)
384
+
385
+
386
+ @app.command("check", help="Run rules over files.")
387
+ def _check_command(
388
+ context: typer.Context,
389
+ files: Annotated[list[Path], typer.Argument()],
390
+ rule: Annotated[list[str], typer.Option("--rule", help="Rule ID (repeat for multiple).")],
391
+ baseline: Annotated[
392
+ Path | None, typer.Option(help="Per-file shrink-only baseline JSON: {path: {CODE: count}}.")
393
+ ] = None,
394
+ update_baseline: Annotated[
395
+ Path | None, typer.Option(help="Write current per-file diagnostic counts to JSON and exit 0.")
396
+ ] = None,
397
+ ) -> None:
398
+ context.ensure_object(_Result).code = _run_check(rule, files, baseline, update_baseline)
399
+
400
+
401
+ def main(argv: list[str] | None = None) -> int:
402
+ result = _Result()
403
+ try:
404
+ app(args=argv, prog_name="sarj-python-lint", obj=result)
405
+ except SystemExit as exc:
406
+ if exc.code != 0:
407
+ raise
408
+ return result.code
409
+
410
+
404
411
  if __name__ == "__main__":
405
412
  sys.exit(main())
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from typing import final
4
4
 
5
+ from sarj_python_lint._python_target import PythonTargetFacts
5
6
  from sarj_python_lint.rules._first_party import FirstPartyFacts
6
7
 
7
8
 
@@ -9,3 +10,4 @@ from sarj_python_lint.rules._first_party import FirstPartyFacts
9
10
  class AnalysisSession:
10
11
  def __init__(self) -> None:
11
12
  self.first_party = FirstPartyFacts()
13
+ self.python_target = PythonTargetFacts()
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ from importlib import metadata
5
+ from pathlib import Path
6
+ import tomllib
7
+ from typing import TypeGuard, final
8
+
9
+ from packaging.specifiers import InvalidSpecifier, SpecifierSet
10
+ from packaging.version import InvalidVersion, Version
11
+
12
+
13
+ _MAX_ANCESTORS = 24
14
+ _PROJECT_BOUNDARIES = ("pyproject.toml", "setup.py", "setup.cfg")
15
+ _INSTALLED_ROOTS = frozenset({"site-packages", "dist-packages"})
16
+ _PYTHON_MAJORS = frozenset({2, 3})
17
+
18
+
19
+ @final
20
+ class PythonTargetFacts:
21
+ def __init__(self) -> None:
22
+ self._declarations: dict[Path, str | None] = {}
23
+ self._projects: dict[Path, str | None] = {}
24
+ self._installed: dict[Path, dict[Path, str | None]] = {}
25
+ self._witnesses: dict[tuple[str, tuple[int, int]], bool] = {}
26
+
27
+ def has_declared_support_before(self, path: Path, minimum: tuple[int, int]) -> bool:
28
+ try:
29
+ absolute = path.absolute()
30
+ resolved = path.resolve()
31
+ except OSError, RuntimeError:
32
+ return False
33
+ if absolute not in self._declarations:
34
+ try:
35
+ self._declarations[absolute] = self._declaration(absolute, resolved)
36
+ except OSError, RuntimeError:
37
+ self._declarations[absolute] = None
38
+ declaration = self._declarations[absolute]
39
+ if not declaration:
40
+ return False
41
+ key = declaration, minimum
42
+ if key not in self._witnesses:
43
+ self._witnesses[key] = _has_legacy_witness(declaration, minimum)
44
+ return self._witnesses[key]
45
+
46
+ def _declaration(self, absolute: Path, resolved: Path) -> str | None:
47
+ ancestors = [*list(absolute.parents)[:_MAX_ANCESTORS], *list(resolved.parents)[:_MAX_ANCESTORS]]
48
+ for ancestor in ancestors:
49
+ if ancestor.name in _INSTALLED_ROOTS:
50
+ root = ancestor.resolve()
51
+ if not resolved.is_relative_to(root):
52
+ return None
53
+ if root not in self._installed:
54
+ self._installed[root] = _installed_declarations(root)
55
+ return self._installed[root].get(resolved)
56
+ for ancestor in list(resolved.parents)[:_MAX_ANCESTORS]:
57
+ try:
58
+ if any((ancestor / marker).exists() for marker in _PROJECT_BOUNDARIES):
59
+ if ancestor not in self._projects:
60
+ self._projects[ancestor] = _project_declaration(ancestor / "pyproject.toml")
61
+ return self._projects[ancestor]
62
+ if (ancestor / ".git").exists():
63
+ return None
64
+ except OSError:
65
+ return None
66
+ return None
67
+
68
+
69
+ def _project_declaration(path: Path) -> str | None:
70
+ try:
71
+ document = tomllib.loads(path.read_text(encoding="utf-8"))
72
+ except OSError, UnicodeError, tomllib.TOMLDecodeError:
73
+ return None
74
+ project = document.get("project")
75
+ if not _is_toml_table(project):
76
+ return None
77
+ dynamic = project.get("dynamic", [])
78
+ if not _is_toml_array(dynamic) or any(not isinstance(field, str) for field in dynamic):
79
+ return None
80
+ if "requires-python" in dynamic:
81
+ return None
82
+ declaration = project.get("requires-python")
83
+ return declaration if isinstance(declaration, str) else None
84
+
85
+
86
+ def _is_toml_table(value: object) -> TypeGuard[dict[str, object]]:
87
+ return isinstance(value, dict)
88
+
89
+
90
+ def _is_toml_array(value: object) -> TypeGuard[list[object]]:
91
+ return isinstance(value, list)
92
+
93
+
94
+ def _installed_declarations(root: Path) -> dict[Path, str | None]:
95
+ try:
96
+ return _collect_installed_declarations(root)
97
+ except OSError, UnicodeError, ValueError, RuntimeError, TypeError, csv.Error:
98
+ return {}
99
+
100
+
101
+ def _collect_installed_declarations(root: Path) -> dict[Path, str | None]:
102
+ owners: dict[Path, str | None] = {}
103
+ for distribution in metadata.distributions(path=[str(root)]):
104
+ declarations = distribution.metadata.get_all("Requires-Python") or []
105
+ declaration = declarations[0] if len(declarations) == 1 else None
106
+ seen: set[Path] = set()
107
+ for file in distribution.files or ():
108
+ located = Path(str(distribution.locate_file(file))).resolve()
109
+ if not located.is_relative_to(root) or located in seen:
110
+ continue
111
+ seen.add(located)
112
+ owners[located] = None if located in owners else declaration
113
+ return owners
114
+
115
+
116
+ def _has_legacy_witness(declaration: str, minimum: tuple[int, int]) -> bool:
117
+ try:
118
+ specifiers = SpecifierSet(declaration)
119
+ except InvalidSpecifier:
120
+ return False
121
+ if not specifiers or any(specifier.operator == "===" for specifier in specifiers):
122
+ return False
123
+ threshold = Version(".".join(map(str, minimum)))
124
+ candidates = {Version(f"3.{minor}.{patch}") for minor in range(minimum[1]) for patch in (0, 1)}
125
+ candidates.add(Version("2.7"))
126
+ for specifier in specifiers:
127
+ try:
128
+ boundary = Version(specifier.version.removesuffix(".*"))
129
+ except InvalidVersion:
130
+ continue
131
+ if boundary.epoch:
132
+ continue
133
+ release = (*boundary.release, 0, 0)[:3]
134
+ candidates.add(Version(".".join(map(str, release))))
135
+ candidates.add(Version(f"{release[0]}.{release[1]}.{release[2] + 1}"))
136
+ return any(
137
+ candidate.major in _PYTHON_MAJORS
138
+ and candidate < threshold
139
+ and specifiers.contains(candidate, prereleases=False)
140
+ for candidate in candidates
141
+ )
@@ -1,11 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
- import argparse
3
+ from dataclasses import dataclass
4
4
  import json
5
5
  from pathlib import Path
6
6
  import subprocess # ruff: ignore[suspicious-subprocess-import] -- fixed local Ruff introspection only
7
7
  import sys
8
- from typing import TYPE_CHECKING, TypeGuard
8
+ from typing import TYPE_CHECKING, Annotated, TypeGuard
9
+
10
+ import typer
9
11
 
10
12
  from sarj_python_lint._filesystem import atomic_write_text
11
13
  from sarj_python_lint._version import __version__
@@ -29,7 +31,8 @@ if TYPE_CHECKING:
29
31
  _DEFAULT_BASELINE_NAME = "suppression-baseline.json"
30
32
 
31
33
 
32
- class _Args(argparse.Namespace):
34
+ @dataclass(frozen=True)
35
+ class _Options:
33
36
  root: Path
34
37
  baseline: Path | None
35
38
  package: list[str]
@@ -38,19 +41,8 @@ class _Args(argparse.Namespace):
38
41
  update: bool
39
42
  allow_increase: bool
40
43
 
41
- def __init__(self) -> None:
42
- super().__init__()
43
- self.root = Path()
44
- self.baseline = None
45
- self.package = []
46
- self.exclude_subtree = []
47
- self.per_file_ceiling = None
48
- self.update = False
49
- self.allow_increase = False
50
-
51
44
 
52
- def main(argv: list[str] | None = None) -> int:
53
- args = _build_parser().parse_args(argv, namespace=_Args())
45
+ def _run(args: _Options) -> int:
54
46
  root = args.root.resolve()
55
47
  baseline_path = args.baseline if args.baseline is not None else root / _DEFAULT_BASELINE_NAME
56
48
 
@@ -180,48 +172,68 @@ def _is_string_object_mapping(value: object) -> TypeGuard[dict[str, object]]:
180
172
  return isinstance(value, dict)
181
173
 
182
174
 
183
- def _build_parser() -> argparse.ArgumentParser:
184
- parser = argparse.ArgumentParser(
185
- prog="sarj-ratchet",
186
- description=(
187
- "Ratchet on lint/type suppressions: per-code, per-package and per-file ceilings that may only shrink."
175
+ @dataclass
176
+ class _Result:
177
+ code: int = 0
178
+
179
+
180
+ app = typer.Typer(
181
+ help="Ratchet on lint/type suppressions: per-code, per-package and per-file ceilings that may only shrink.",
182
+ add_completion=False,
183
+ no_args_is_help=False,
184
+ pretty_exceptions_enable=False,
185
+ context_settings={"help_option_names": ["-h", "--help"]},
186
+ )
187
+
188
+
189
+ def _show_version(*, value: bool) -> None:
190
+ if value:
191
+ sys.stdout.write(f"sarj-ratchet {__version__}\n")
192
+ raise typer.Exit
193
+
194
+
195
+ @app.command()
196
+ def _command(
197
+ context: typer.Context,
198
+ *,
199
+ root: Annotated[Path, typer.Argument(help="tree to scan (default: cwd)")] = Path(),
200
+ baseline: Annotated[
201
+ Path | None, typer.Option(help=f"baseline JSON (default: <root>/{_DEFAULT_BASELINE_NAME})")
202
+ ] = None,
203
+ package: Annotated[
204
+ list[str] | None,
205
+ typer.Option(
206
+ "--package", help="package directory relative to root (repeatable; default: baseline or auto-discovered)"
188
207
  ),
208
+ ] = None,
209
+ exclude_subtree: Annotated[
210
+ list[str] | None, typer.Option("--exclude-subtree", help="root-relative path prefix to skip (repeatable)")
211
+ ] = None,
212
+ per_file_ceiling: Annotated[
213
+ int | None,
214
+ typer.Option(help=f"max suppressions in any one file (default: baseline or {DEFAULT_PER_FILE_CEILING})"),
215
+ ] = None,
216
+ update: Annotated[
217
+ bool, typer.Option("--update", help="rewrite baseline from current counts (refuses to raise a ceiling)")
218
+ ] = False,
219
+ allow_increase: Annotated[
220
+ bool, typer.Option("--allow-increase", help="with --update: permit ceilings to rise (requires explicit review)")
221
+ ] = False,
222
+ _version: Annotated[bool, typer.Option("--version", callback=_show_version, is_eager=True)] = False,
223
+ ) -> None:
224
+ context.ensure_object(_Result).code = _run(
225
+ _Options(root, baseline, package or [], exclude_subtree or [], per_file_ceiling, update, allow_increase)
189
226
  )
190
- parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
191
- parser.add_argument("root", nargs="?", type=Path, default=Path(), help="tree to scan (default: cwd)")
192
- parser.add_argument(
193
- "--baseline",
194
- type=Path,
195
- help=f"baseline JSON (default: <root>/{_DEFAULT_BASELINE_NAME})",
196
- )
197
- parser.add_argument(
198
- "--package",
199
- action="append",
200
- default=[],
201
- help="package directory relative to root (repeatable; default: the baseline's, else auto-discovered)",
202
- )
203
- parser.add_argument(
204
- "--exclude-subtree",
205
- action="append",
206
- default=[],
207
- help="root-relative path prefix to skip, e.g. generated client output (repeatable)",
208
- )
209
- parser.add_argument(
210
- "--per-file-ceiling",
211
- type=int,
212
- help=f"max suppressions in any one file (default: the baseline's, else {DEFAULT_PER_FILE_CEILING})",
213
- )
214
- parser.add_argument(
215
- "--update",
216
- action="store_true",
217
- help="rewrite the baseline from current counts (refuses to raise a ceiling)",
218
- )
219
- parser.add_argument(
220
- "--allow-increase",
221
- action="store_true",
222
- help="with --update: permit ceilings to rise (requires explicit review)",
223
- )
224
- return parser
227
+
228
+
229
+ def main(argv: list[str] | None = None) -> int:
230
+ result = _Result()
231
+ try:
232
+ app(args=argv, prog_name="sarj-ratchet", obj=result)
233
+ except SystemExit as exc:
234
+ if exc.code != 0:
235
+ raise
236
+ return result.code
225
237
 
226
238
 
227
239
  def _update(
@@ -8,6 +8,8 @@ from pathlib import Path, PurePosixPath
8
8
  import re
9
9
  from typing import TYPE_CHECKING, ClassVar, Final, Self
10
10
 
11
+ from sarj_python_lint._python_target import PythonTargetFacts
12
+
11
13
 
12
14
  if TYPE_CHECKING:
13
15
  from collections.abc import Sequence
@@ -243,6 +245,10 @@ class Rule(ABC):
243
245
  def prepare_session(self, session: AnalysisSession) -> None:
244
246
  self._analysis_session = session
245
247
 
248
+ def has_declared_python_support_before(self, path: Path, minimum: tuple[int, int]) -> bool:
249
+ facts = self._analysis_session.python_target if self._analysis_session is not None else PythonTargetFacts()
250
+ return facts.has_declared_support_before(path, minimum)
251
+
246
252
  @abstractmethod
247
253
  def check(self, path: Path, source: str) -> list[Diagnostic]:
248
254
  raise NotImplementedError
@@ -92,6 +92,7 @@ from sarj_python_lint.rules.prefer_immutable_module_constant import (
92
92
  from sarj_python_lint.rules.prefer_library_fake import PreferLibraryFake
93
93
  from sarj_python_lint.rules.prefer_match_assert_never import PreferMatchAssertNever
94
94
  from sarj_python_lint.rules.prefer_match_type_dispatch import PreferMatchTypeDispatch
95
+ from sarj_python_lint.rules.prefer_match_value_dispatch import PreferMatchValueDispatch
95
96
  from sarj_python_lint.rules.prefer_module_level_constant import (
96
97
  PreferModuleLevelConstant,
97
98
  )
@@ -218,6 +219,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
218
219
  RequirePortForService.id: RequirePortForService,
219
220
  PreferNonNullableCollection.id: PreferNonNullableCollection,
220
221
  PreferMatchTypeDispatch.id: PreferMatchTypeDispatch,
222
+ PreferMatchValueDispatch.id: PreferMatchValueDispatch,
221
223
  PreferWalrusRegexMatch.id: PreferWalrusRegexMatch,
222
224
  PreferWalrusComprehensionFilter.id: PreferWalrusComprehensionFilter,
223
225
  PreferWalrusStreamLoop.id: PreferWalrusStreamLoop,
@@ -4,6 +4,7 @@ import ast
4
4
  from pathlib import PurePosixPath
5
5
  import re
6
6
  import tokenize
7
+ import tomllib
7
8
  from typing import TYPE_CHECKING, ClassVar, override
8
9
 
9
10
  from sarj_python_lint.rule_base import (
@@ -227,6 +228,27 @@ _CODING_COOKIE_RE = re.compile(r"coding[:=]\s*[-_.a-zA-Z0-9]+")
227
228
  # Only `>>>` arms a doctest block.
228
229
  _DOCTEST_PROMPT = ">>>"
229
230
 
231
+ _SCRIPT_METADATA_RE = re.compile(r"^# /// script\n(?P<content>(?:#(?: .*)?\n)+)# ///(?=\n|$)", re.MULTILINE)
232
+
233
+
234
+ def _script_metadata_lines(source: str, standalone: Sequence[PositionedComment]) -> frozenset[int]:
235
+ normalized = "\n".join(source.splitlines())
236
+ matches = list(_SCRIPT_METADATA_RE.finditer(normalized))
237
+ if len(matches) != 1:
238
+ return frozenset()
239
+ match = matches[0]
240
+ first = normalized.count("\n", 0, match.start()) + 1
241
+ lines = frozenset(range(first, first + match.group().count("\n") + 1))
242
+ top_level = {line for line, col, _ in standalone if col == 0}
243
+ if not lines <= top_level:
244
+ return frozenset()
245
+ content = "\n".join(line[2:] if line.startswith("# ") else "" for line in match.group("content").splitlines())
246
+ try:
247
+ tomllib.loads(content)
248
+ except tomllib.TOMLDecodeError:
249
+ return frozenset()
250
+ return lines
251
+
230
252
 
231
253
  def _is_coding_cookie(body: str) -> bool:
232
254
  return bool(_CODING_COOKIE_RE.search(body))
@@ -499,6 +521,8 @@ class NoCommentCruft(Rule):
499
521
  standalone, first_code_line = standalone_comments(source)
500
522
  except tokenize.TokenError, IndentationError, SyntaxError:
501
523
  return []
524
+ skip = _script_metadata_lines(source, standalone)
525
+ standalone = [comment for comment in standalone if comment.line not in skip]
502
526
  diags: dict[int, Diagnostic] = {}
503
527
  by_line = {line: (col, body) for line, col, body in standalone}
504
528
  skip = _doctest_block_lines(standalone) | _illustration_block_lines(standalone)
@@ -244,15 +244,17 @@ def _annotation_kind(
244
244
  if direct is not None:
245
245
  return direct
246
246
  tail = _tail(value)
247
- if tail == "Annotated":
248
- first = content.elts[0] if isinstance(content, ast.Tuple) and content.elts else content
249
- return _annotation_kind(first, types, seen)
250
- if tail == "Optional":
251
- return _annotation_kind(content, types, seen)
252
- if tail == "Union":
253
- elements = content.elts if isinstance(content, ast.Tuple) else [content]
254
- return _compatible_union_kind(elements, types, seen)
255
- return None
247
+ match tail:
248
+ case "Annotated":
249
+ first = content.elts[0] if isinstance(content, ast.Tuple) and content.elts else content
250
+ return _annotation_kind(first, types, seen)
251
+ case "Optional":
252
+ return _annotation_kind(content, types, seen)
253
+ case "Union":
254
+ elements = content.elts if isinstance(content, ast.Tuple) else [content]
255
+ return _compatible_union_kind(elements, types, seen)
256
+ case _:
257
+ return None
256
258
  case ast.BinOp(left=left, op=ast.BitOr(), right=right):
257
259
  return _compatible_union_kind([left, right], types, seen)
258
260
  case ast.Tuple(elts=elements):
@@ -101,6 +101,7 @@ class PreferMatchTypeDispatch(Rule):
101
101
  limitations=(
102
102
  "Only three or more adjacent, unguarded `isinstance` branches over the same simple name are checked.",
103
103
  "The checked types must be unshadowed builtins, unshadowed module-local classes, or proven stdlib ast classes; unresolved imports, runtime type groups, repeated type references, generated files, and non-terminating sibling checks are excluded.",
104
+ "Declared support for Python before 3.10 suppresses this recommendation when proven by the nearest project metadata or exact installed-distribution ownership. Missing or ambiguous target metadata retains advisory behavior; it does not prove a modern target.",
104
105
  ),
105
106
  examples=(
106
107
  RuleExample(
@@ -185,6 +186,8 @@ class PreferMatchTypeDispatch(Rule):
185
186
  unsafe_bindings=unsafe_bindings,
186
187
  )
187
188
  )
189
+ if findings and self.has_declared_python_support_before(path, (3, 10)):
190
+ return []
188
191
  findings.sort(key=lambda diagnostic: (diagnostic.line, diagnostic.col))
189
192
  return findings
190
193