sarj-python-lint 0.80.17__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 (126) hide show
  1. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/PKG-INFO +2 -1
  2. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/pyproject.toml +2 -2
  3. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/_analysis_session.py +2 -0
  4. sarj_python_lint-0.81.0/src/sarj_python_lint/_python_target.py +141 -0
  5. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rule_base.py +6 -0
  6. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +11 -9
  7. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +3 -0
  8. sarj_python_lint-0.81.0/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +394 -0
  9. sarj_python_lint-0.80.17/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -230
  10. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/.gitignore +0 -0
  11. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/LICENSE +0 -0
  12. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/README.md +0 -0
  13. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/__init__.py +0 -0
  14. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/__main__.py +0 -0
  15. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/_filesystem.py +0 -0
  16. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  17. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/_secret_names.py +0 -0
  18. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/_version.py +0 -0
  19. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/py.typed +0 -0
  20. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/ratchet.py +0 -0
  21. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  22. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  23. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  24. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  25. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  26. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  27. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  28. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  29. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  30. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  31. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
  32. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  33. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  34. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_registry.py +0 -0
  35. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  36. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  37. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  38. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  39. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  40. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  41. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  42. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  43. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  44. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  45. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  46. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  47. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  48. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  49. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  50. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  51. {sarj_python_lint-0.80.17 → 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.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  53. {sarj_python_lint-0.80.17 → 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.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  55. {sarj_python_lint-0.80.17 → 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.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  57. {sarj_python_lint-0.80.17 → 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.17 → 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.17 → 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.17 → 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.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  62. {sarj_python_lint-0.80.17 → 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.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  64. {sarj_python_lint-0.80.17 → 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.17 → 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.17 → 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.17 → 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.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  69. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  70. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  71. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  72. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  73. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  74. {sarj_python_lint-0.80.17 → 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.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  76. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  77. {sarj_python_lint-0.80.17 → 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.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  79. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  80. {sarj_python_lint-0.80.17 → 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.17 → 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.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  83. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  84. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  85. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  86. {sarj_python_lint-0.80.17 → 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.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  88. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  89. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  90. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  91. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  92. {sarj_python_lint-0.80.17 → 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.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  94. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  95. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  96. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  97. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  98. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  99. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  100. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  101. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  102. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  103. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  104. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  105. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  106. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  107. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  108. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  109. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  110. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  111. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  112. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  113. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  114. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  115. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  116. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  117. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  118. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  119. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  120. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  121. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  122. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  123. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  124. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  125. {sarj_python_lint-0.80.17 → sarj_python_lint-0.81.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  126. {sarj_python_lint-0.80.17 → 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.17
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,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: packaging==26.3
20
21
  Requires-Dist: ruff>=0.16.5
21
22
  Requires-Dist: sqlglot==30.18.0
22
23
  Requires-Dist: typer==0.27.2
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.80.17"
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", "typer==0.27.2"]
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"
@@ -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
+ )
@@ -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
@@ -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
 
@@ -0,0 +1,394 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from collections import Counter
5
+ from pathlib import PurePosixPath
6
+ from typing import TYPE_CHECKING, ClassVar, final, override
7
+
8
+ from sarj_python_lint.rule_base import (
9
+ AutofixPolicy,
10
+ Diagnostic,
11
+ ExampleFile,
12
+ ExampleOutcome,
13
+ Rule,
14
+ RuleCategory,
15
+ RuleDocumentation,
16
+ RuleExample,
17
+ Severity,
18
+ parse_or_none,
19
+ )
20
+ from sarj_python_lint.rules._paths import is_generated
21
+
22
+
23
+ if TYPE_CHECKING:
24
+ from pathlib import Path
25
+
26
+
27
+ type _Literal = str | bytes | int | float
28
+ _MIN_TESTS = 2
29
+ _MIN_EXPANDED_ARMS = 3
30
+ _GUARDED_TEST_PARTS = 2
31
+ _MIN_GROUP_VALUES = 2
32
+ _MAX_GROUP_VALUES = 6
33
+
34
+
35
+ @final
36
+ class PreferMatchValueDispatch(Rule):
37
+ id: str = "prefer-match-value-dispatch"
38
+ code: str = "SARJ439"
39
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
40
+ summary="Prefer match/case for repeated dispatch on one value with a fallback.",
41
+ rationale="A single dispatch subject makes distinct value cases and their fallback easier to review.",
42
+ remediation=(
43
+ "Consider match/case while preserving fallback and constant semantics; use literal or dotted value "
44
+ "patterns, never bare constant names that capture instead of compare. Review membership versus equality, "
45
+ "subject evaluation, and guard order before rewriting."
46
+ ),
47
+ category=RuleCategory.MAINTAINABILITY,
48
+ autofix=AutofixPolicy.NONE,
49
+ limitations=(
50
+ "Equality-only ladders require two or more tests on the same left-hand name or attribute chain, followed by else.",
51
+ "Cases must be distinct string, bytes, or numeric literals, or singly bound module constants containing them; shadowed constants and singleton values are excluded.",
52
+ "This is a readability suggestion, not proof of exhaustiveness or a safe automatic rewrite: attributes can have side effects and match evaluates its subject once.",
53
+ "Grouped or guarded ladders and terminal sibling branches require three distinct arms on a bare name; only the last arm may have a selector-first guard.",
54
+ "Groups contain two to six distinct string literals in a tuple, list, or set. Set hashing and custom equality can differ from pattern matching; no runtime string type is inferred.",
55
+ "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.",
56
+ ),
57
+ examples=(
58
+ RuleExample(
59
+ example_id="grouped-terminal-suffix-dispatch",
60
+ scenario="grouped-terminal-dispatch",
61
+ title="Group terminal format dispatch under one subject",
62
+ outcome=ExampleOutcome.MATCH,
63
+ files=(
64
+ ExampleFile.python(
65
+ "app/formats.py",
66
+ "def parse_config(suffix: str, source: str, jsonc_is_valid: bool):\n"
67
+ " if suffix in {'.yaml', '.yml'}:\n"
68
+ " return ('yaml', source)\n"
69
+ " if suffix == '.toml':\n"
70
+ " return ('toml', source)\n"
71
+ " if suffix == '.jsonc' and jsonc_is_valid:\n"
72
+ " return ('jsonc', source)\n"
73
+ " return None\n",
74
+ ),
75
+ ),
76
+ focus_path=PurePosixPath("app/formats.py"),
77
+ expected_count=1,
78
+ public=True,
79
+ ),
80
+ RuleExample(
81
+ example_id="grouped-suffix-match-with-guard",
82
+ scenario="grouped-terminal-dispatch",
83
+ title="Retain grouped alternatives, the guard, and the fallback",
84
+ outcome=ExampleOutcome.NO_MATCH,
85
+ files=(
86
+ ExampleFile.python(
87
+ "app/formats.py",
88
+ "def parse_config(suffix: str, source: str, jsonc_is_valid: bool):\n"
89
+ " match suffix:\n"
90
+ " case '.yaml' | '.yml':\n"
91
+ " return ('yaml', source)\n"
92
+ " case '.toml':\n"
93
+ " return ('toml', source)\n"
94
+ " case '.jsonc' if jsonc_is_valid:\n"
95
+ " return ('jsonc', source)\n"
96
+ " case _:\n"
97
+ " return None\n",
98
+ ),
99
+ ),
100
+ focus_path=PurePosixPath("app/formats.py"),
101
+ expected_count=0,
102
+ public=True,
103
+ ),
104
+ RuleExample(
105
+ example_id="named-file-dispatch",
106
+ title="Make repeated filename dispatch explicit",
107
+ outcome=ExampleOutcome.MATCH,
108
+ files=(
109
+ ExampleFile.python(
110
+ "app/migrate.py",
111
+ "_ESLINT_SUPPRESSIONS = 'eslint.json'\n"
112
+ "_RATCHET_SUPPRESSIONS = 'ratchet.json'\n"
113
+ "def migrate(path):\n"
114
+ " if path.name == _ESLINT_SUPPRESSIONS:\n"
115
+ " migrated = rewrite_eslint(path)\n"
116
+ " elif path.name == _RATCHET_SUPPRESSIONS:\n"
117
+ " migrated = rewrite_ratchet(path)\n"
118
+ " else:\n"
119
+ " migrated = rewrite(path)\n"
120
+ " return migrated\n",
121
+ ),
122
+ ),
123
+ focus_path=PurePosixPath("app/migrate.py"),
124
+ expected_count=1,
125
+ public=True,
126
+ ),
127
+ RuleExample(
128
+ example_id="literal-match-dispatch",
129
+ title="Use value cases and retain the fallback",
130
+ outcome=ExampleOutcome.NO_MATCH,
131
+ files=(
132
+ ExampleFile.python(
133
+ "app/migrate.py",
134
+ "def migrate(path):\n"
135
+ " match path.name:\n"
136
+ " case 'eslint.json':\n"
137
+ " return rewrite_eslint(path)\n"
138
+ " case 'ratchet.json':\n"
139
+ " return rewrite_ratchet(path)\n"
140
+ " case _:\n"
141
+ " return rewrite(path)\n",
142
+ ),
143
+ ),
144
+ focus_path=PurePosixPath("app/migrate.py"),
145
+ expected_count=0,
146
+ public=True,
147
+ ),
148
+ ),
149
+ )
150
+ description: str = documentation.summary
151
+
152
+ @override
153
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
154
+ if is_generated(path, source):
155
+ return []
156
+ tree = parse_or_none(path, source)
157
+ if tree is None:
158
+ return []
159
+ constants = _module_constants(tree)
160
+ lines = source.splitlines()
161
+ continuations: set[int] = set()
162
+ findings: list[Diagnostic] = []
163
+ candidates: list[tuple[ast.If, str]] = []
164
+ for node in ast.walk(tree):
165
+ if not isinstance(node, ast.If) or id(node) in continuations:
166
+ continue
167
+ branches = [node]
168
+ current = node
169
+ while len(current.orelse) == 1:
170
+ child = current.orelse[0]
171
+ if not isinstance(child, ast.If) or not lines[child.lineno - 1].lstrip().startswith("elif"):
172
+ break
173
+ continuations.add(id(child))
174
+ branches.append(child)
175
+ current = child
176
+ if len(branches) < _MIN_TESTS or not current.orelse:
177
+ continue
178
+ subject = _dispatch_subject(branches, constants)
179
+ if subject is None:
180
+ subject = _expanded_dispatch_subject(branches, constants)
181
+ if subject is not None:
182
+ candidates.append((node, subject))
183
+ for branches in _terminal_sibling_dispatches(tree):
184
+ subject = _expanded_dispatch_subject(branches, constants)
185
+ if subject is not None:
186
+ candidates.append((branches[0], subject))
187
+ if candidates and self.has_declared_python_support_before(path, (3, 10)):
188
+ return []
189
+ for node, subject in sorted(candidates, key=lambda candidate: candidate[0].lineno):
190
+ findings.append(
191
+ Diagnostic(
192
+ path=path,
193
+ line=node.lineno,
194
+ col=node.col_offset + 1,
195
+ code=self.code,
196
+ severity=Severity.WARNING,
197
+ message=(
198
+ f"Repeated value dispatch on '{subject}' with a fallback — consider match/case; "
199
+ "preserve fallback and constant semantics, and review repeated attribute evaluation, "
200
+ "membership versus equality, and guard order."
201
+ ),
202
+ )
203
+ )
204
+ return findings
205
+
206
+
207
+ def _terminal_sibling_dispatches(tree: ast.Module) -> list[list[ast.If]]:
208
+ candidates: list[list[ast.If]] = []
209
+ for node in ast.walk(tree):
210
+ for field in _statement_lists(node):
211
+ branches: list[ast.If] = []
212
+ for statement in field:
213
+ if isinstance(statement, ast.If) and not statement.orelse:
214
+ branches.append(statement)
215
+ continue
216
+ if (
217
+ len(branches) >= _MIN_EXPANDED_ARMS
218
+ and isinstance(statement, (ast.Return, ast.Raise))
219
+ and all(branch.body and isinstance(branch.body[-1], (ast.Return, ast.Raise)) for branch in branches)
220
+ ):
221
+ candidates.append(branches)
222
+ branches = []
223
+ return candidates
224
+
225
+
226
+ def _statement_lists(node: ast.AST) -> tuple[list[ast.stmt], ...]:
227
+ match node:
228
+ case (
229
+ ast.Module()
230
+ | ast.FunctionDef()
231
+ | ast.AsyncFunctionDef()
232
+ | ast.ClassDef()
233
+ | ast.With()
234
+ | ast.AsyncWith()
235
+ | ast.ExceptHandler()
236
+ | ast.match_case()
237
+ ):
238
+ return (node.body,)
239
+ case ast.If() | ast.For() | ast.AsyncFor() | ast.While():
240
+ return node.body, node.orelse
241
+ case ast.Try() | ast.TryStar():
242
+ return node.body, node.orelse, node.finalbody
243
+ case _:
244
+ return ()
245
+
246
+
247
+ def _expanded_dispatch_subject(branches: list[ast.If], constants: dict[str, _Literal]) -> str | None:
248
+ if len(branches) < _MIN_EXPANDED_ARMS:
249
+ return None
250
+ subject: str | None = None
251
+ values: set[_Literal] = set()
252
+ bodies: set[tuple[str, ...]] = set()
253
+ for index, branch in enumerate(branches):
254
+ body = tuple(ast.dump(statement) for statement in branch.body)
255
+ if body in bodies:
256
+ return None
257
+ bodies.add(body)
258
+ selector = branch.test
259
+ if isinstance(selector, ast.BoolOp) and isinstance(selector.op, ast.And):
260
+ if index != len(branches) - 1 or len(selector.values) != _GUARDED_TEST_PARTS:
261
+ return None
262
+ if any(
263
+ isinstance(node, (ast.NamedExpr, ast.Await, ast.Yield, ast.YieldFrom))
264
+ for node in ast.walk(selector.values[1])
265
+ ):
266
+ return None
267
+ selector = selector.values[0]
268
+ parsed = _expanded_selector(selector, constants)
269
+ if parsed is None:
270
+ return None
271
+ candidate, alternatives = parsed
272
+ if (
273
+ candidate in constants
274
+ or (subject is not None and candidate != subject)
275
+ or values.intersection(alternatives)
276
+ ):
277
+ return None
278
+ subject = candidate
279
+ values.update(alternatives)
280
+ return subject
281
+
282
+
283
+ def _expanded_selector(expression: ast.expr, constants: dict[str, _Literal]) -> tuple[str, set[_Literal]] | None:
284
+ match expression:
285
+ case ast.Compare(left=ast.Name(id=name), ops=[ast.Eq()], comparators=[right]):
286
+ value = constants.get(right.id) if isinstance(right, ast.Name) else _literal(right)
287
+ return (name, {value}) if value is not None else None
288
+ case ast.Compare(
289
+ left=ast.Name(id=name),
290
+ ops=[ast.In()],
291
+ comparators=[ast.Tuple(elts=items) | ast.List(elts=items) | ast.Set(elts=items)],
292
+ ):
293
+ if not _MIN_GROUP_VALUES <= len(items) <= _MAX_GROUP_VALUES:
294
+ return None
295
+ values: set[_Literal] = set()
296
+ for item in items:
297
+ if not isinstance(item, ast.Constant) or not isinstance(item.value, str) or item.value in values:
298
+ return None
299
+ values.add(item.value)
300
+ return name, values
301
+ case _:
302
+ return None
303
+
304
+
305
+ def _module_constants(tree: ast.Module) -> dict[str, _Literal]:
306
+ bindings: Counter[str] = Counter()
307
+ for node in ast.walk(tree):
308
+ match node:
309
+ case (
310
+ ast.Name(id=name, ctx=ast.Store() | ast.Del())
311
+ | ast.arg(arg=name)
312
+ | ast.FunctionDef(name=name)
313
+ | ast.AsyncFunctionDef(name=name)
314
+ | ast.ClassDef(name=name)
315
+ | ast.TypeVar(name=name)
316
+ | ast.ParamSpec(name=name)
317
+ | ast.TypeVarTuple(name=name)
318
+ ):
319
+ bindings[name] += 1
320
+ case ast.alias(name=name, asname=alias):
321
+ if name == "*":
322
+ return {}
323
+ bindings[alias or name.partition(".")[0]] += 1
324
+ case (
325
+ ast.ExceptHandler(name=name)
326
+ | ast.MatchAs(name=name)
327
+ | ast.MatchStar(name=name)
328
+ | ast.MatchMapping(rest=name)
329
+ ):
330
+ if name is not None:
331
+ bindings[name] += 1
332
+ case ast.Global(names=names) | ast.Nonlocal(names=names):
333
+ bindings.update(names)
334
+ case _:
335
+ pass
336
+ constants: dict[str, _Literal] = {}
337
+ for statement in tree.body:
338
+ match statement:
339
+ case ast.Assign(targets=[ast.Name(id=name)], value=value):
340
+ literal = _literal(value)
341
+ case ast.AnnAssign(target=ast.Name(id=name), value=value) if value is not None:
342
+ literal = _literal(value)
343
+ case _:
344
+ continue
345
+ if literal is not None and bindings[name] == 1:
346
+ constants[name] = literal
347
+ return constants
348
+
349
+
350
+ def _literal(expression: ast.expr) -> _Literal | None:
351
+ match expression:
352
+ case ast.Constant(value=value) if isinstance(value, (str, bytes, int, float)) and not isinstance(value, bool):
353
+ return value
354
+ case ast.UnaryOp(op=ast.USub() | ast.UAdd(), operand=ast.Constant(value=value)) if isinstance(
355
+ value, (int, float)
356
+ ) and not isinstance(value, bool):
357
+ return -value if isinstance(expression.op, ast.USub) else value
358
+ case _:
359
+ return None
360
+
361
+
362
+ def _dispatch_subject(branches: list[ast.If], constants: dict[str, _Literal]) -> str | None:
363
+ subject: str | None = None
364
+ values: set[_Literal] = set()
365
+ bodies: set[tuple[str, ...]] = set()
366
+ for branch in branches:
367
+ body = tuple(ast.dump(statement) for statement in branch.body)
368
+ if body in bodies:
369
+ return None
370
+ bodies.add(body)
371
+ match branch.test:
372
+ case ast.Compare(left=left, ops=[ast.Eq()], comparators=[right]):
373
+ candidate = _subject(left)
374
+ value = constants.get(right.id) if isinstance(right, ast.Name) else _literal(right)
375
+ case _:
376
+ return None
377
+ if candidate is None or candidate.partition(".")[0] in constants or value is None or value in values:
378
+ return None
379
+ if subject is not None and candidate != subject:
380
+ return None
381
+ subject = candidate
382
+ values.add(value)
383
+ return subject
384
+
385
+
386
+ def _subject(expression: ast.expr) -> str | None:
387
+ match expression:
388
+ case ast.Name(id=name):
389
+ return name
390
+ case ast.Attribute(value=value, attr=attribute):
391
+ parent = _subject(value)
392
+ return f"{parent}.{attribute}" if parent is not None else None
393
+ case _:
394
+ return None