sarj-python-lint 0.73.2__tar.gz → 0.75.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 (118) hide show
  1. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/_registry.py +4 -2
  4. sarj_python_lint-0.75.0/src/sarj_python_lint/rules/get_delegates_to_get_many.py +282 -0
  5. sarj_python_lint-0.75.0/src/sarj_python_lint/rules/prefer_one_for_required_row.py +175 -0
  6. sarj_python_lint-0.73.2/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -121
  7. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/.gitignore +0 -0
  8. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/LICENSE +0 -0
  9. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/README.md +0 -0
  10. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/__init__.py +0 -0
  11. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/__main__.py +0 -0
  12. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/_filesystem.py +0 -0
  13. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  14. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/_secret_names.py +0 -0
  15. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/_version.py +0 -0
  16. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/py.typed +0 -0
  17. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/ratchet.py +0 -0
  18. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rule_base.py +0 -0
  19. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  20. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  21. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  22. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  23. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  24. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  25. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  26. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  27. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  28. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
  29. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  30. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  31. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  32. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  33. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  34. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +0 -0
  35. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
  36. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  37. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  38. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  39. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  40. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
  41. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  42. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  43. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  44. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  45. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  46. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  47. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  48. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  49. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  50. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  51. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  52. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  53. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  54. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  55. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  56. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  57. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  58. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
  59. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  60. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  61. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  62. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  63. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  64. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  65. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  66. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  67. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  68. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  69. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  70. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  71. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  72. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  73. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  74. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  75. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  76. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  77. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  78. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  79. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  80. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  81. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  82. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  83. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  84. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  85. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  86. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  87. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  88. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  89. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  90. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  91. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  92. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  93. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  94. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  95. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  96. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  97. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/preserve_declared_nominal_id.py +0 -0
  98. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/preserve_enum_types.py +0 -0
  99. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  100. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  101. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  102. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  103. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  104. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  105. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  106. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  107. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  108. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  109. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/require_validated_row_factory.py +0 -0
  110. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  111. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/source_coupled_test.py +0 -0
  112. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/sql_requires_injected_pool_owner.py +0 -0
  113. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  114. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  115. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  116. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  117. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  118. {sarj_python_lint-0.73.2 → sarj_python_lint-0.75.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.73.2
3
+ Version: 0.75.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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.73.2"
3
+ version = "0.75.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" }]
@@ -19,6 +19,7 @@ from sarj_python_lint.rules.duplicated_override_docstring import (
19
19
  )
20
20
  from sarj_python_lint.rules.fastapi_openapi_contract import FastapiOpenapiContract
21
21
  from sarj_python_lint.rules.fixture_returns_bare_tuple import FixtureReturnsBareTuple
22
+ from sarj_python_lint.rules.get_delegates_to_get_many import GetDelegatesToGetMany
22
23
  from sarj_python_lint.rules.iac_source_coupled_test import IacSourceCoupledTest
23
24
  from sarj_python_lint.rules.invalid_pydantic_field_default import (
24
25
  InvalidPydanticFieldDefault,
@@ -37,7 +38,6 @@ from sarj_python_lint.rules.no_cors_wildcard_with_credentials import (
37
38
  )
38
39
  from sarj_python_lint.rules.no_duplicate_dunder_all_entry import NoDuplicateDunderAllEntry
39
40
  from sarj_python_lint.rules.no_file_level_escape_hatch_noqa import NoFileLevelEscapeHatchNoqa
40
- from sarj_python_lint.rules.no_file_level_suppression import NoFileLevelSuppression
41
41
  from sarj_python_lint.rules.no_first_party_private_import import (
42
42
  NoFirstPartyPrivateImport,
43
43
  )
@@ -96,6 +96,7 @@ from sarj_python_lint.rules.prefer_nominal_id_types import PreferNominalIdTypes
96
96
  from sarj_python_lint.rules.prefer_non_nullable_collection import (
97
97
  PreferNonNullableCollection,
98
98
  )
99
+ from sarj_python_lint.rules.prefer_one_for_required_row import PreferOneForRequiredRow
99
100
  from sarj_python_lint.rules.prefer_or_pattern import PreferOrPattern
100
101
  from sarj_python_lint.rules.prefer_self_documenting_constant import (
101
102
  PreferSelfDocumentingConstant,
@@ -180,12 +181,13 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
180
181
  PreferMatchAssertNever.id: PreferMatchAssertNever,
181
182
  RequireKeywordOnlySwapProneParams.id: RequireKeywordOnlySwapProneParams,
182
183
  RequirePydanticForExternalJson.id: RequirePydanticForExternalJson,
183
- NoFileLevelSuppression.id: NoFileLevelSuppression,
184
184
  PreferModuleLevelConstant.id: PreferModuleLevelConstant,
185
185
  PreferImmutableModuleConstant.id: PreferImmutableModuleConstant,
186
186
  MockWithoutSpec.id: MockWithoutSpec,
187
187
  OpaqueParametrizeCaseNeedsId.id: OpaqueParametrizeCaseNeedsId,
188
188
  FixtureReturnsBareTuple.id: FixtureReturnsBareTuple,
189
+ GetDelegatesToGetMany.id: GetDelegatesToGetMany,
190
+ PreferOneForRequiredRow.id: PreferOneForRequiredRow,
189
191
  KwargHeavyConstructionInTest.id: KwargHeavyConstructionInTest,
190
192
  DefectXfailRequiresStrict.id: DefectXfailRequiresStrict,
191
193
  NoFirstPartyPrivateImport.id: NoFirstPartyPrivateImport,
@@ -0,0 +1,282 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import PurePosixPath
5
+ from typing import TYPE_CHECKING, ClassVar, NamedTuple, final, override
6
+
7
+ from sarj_python_lint.rule_base import (
8
+ AutofixPolicy,
9
+ Diagnostic,
10
+ ExampleFile,
11
+ ExampleOutcome,
12
+ Rule,
13
+ RuleCategory,
14
+ RuleDocumentation,
15
+ RuleExample,
16
+ Severity,
17
+ parse_or_none,
18
+ )
19
+ from sarj_python_lint.rules._paths import is_generated
20
+ from sarj_python_lint.rules._sql import is_store_module
21
+
22
+
23
+ if TYPE_CHECKING:
24
+ from pathlib import Path
25
+
26
+
27
+ _BULK_NAMES = frozenset({"get_by_ids", "get_many"})
28
+ _BRANCH_NODES = (ast.If, ast.IfExp, ast.Match, ast.Try, ast.TryStar)
29
+ _PAIR_SIZE = 2
30
+
31
+
32
+ class _MethodPair(NamedTuple):
33
+ singleton: ast.FunctionDef | ast.AsyncFunctionDef
34
+ bulk: ast.FunctionDef | ast.AsyncFunctionDef
35
+
36
+
37
+ class _BulkResultTypes(NamedTuple):
38
+ key: ast.expr | None
39
+ value: ast.expr
40
+
41
+
42
+ @final
43
+ class GetDelegatesToGetMany(Rule):
44
+ id = "get-delegates-to-get-many"
45
+ code = "SARJ421"
46
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
47
+ summary="Require compatible singleton store reads to delegate to their bulk implementation.",
48
+ rationale=(
49
+ "Independent singleton and bulk queries can drift in filtering, row conversion, authorization, "
50
+ "and missing-row behavior while maintaining two database access paths."
51
+ ),
52
+ remediation=(
53
+ "Implement `get` through the compatible `get_many([key])` or `get_by_ids([key])` method and "
54
+ "project its documented zero-or-one result."
55
+ ),
56
+ category=RuleCategory.MAINTAINABILITY,
57
+ autofix=AutofixPolicy.NONE,
58
+ limitations=(
59
+ "Only concrete methods declared together in a production store module are inspected.",
60
+ "The methods must have one typed key, a list result for get_many or dict result for get_by_ids, and the same sync shape.",
61
+ "Branching singleton implementations are excluded because caching, locking, validation, or authorization may differ intentionally.",
62
+ ),
63
+ examples=(
64
+ RuleExample(
65
+ example_id="duplicate-singleton-query",
66
+ title="Do not maintain a second singleton query path",
67
+ outcome=ExampleOutcome.MATCH,
68
+ files=(
69
+ ExampleFile.python(
70
+ "app/user_store.py",
71
+ "class UserStore:\n"
72
+ " async def get(self, user_id: UserId) -> User | None:\n"
73
+ " return await self.query_one(user_id)\n\n"
74
+ " async def get_many(self, user_ids: list[UserId]) -> list[User]:\n"
75
+ " return await self.query_many(user_ids)\n",
76
+ ),
77
+ ),
78
+ focus_path=PurePosixPath("app/user_store.py"),
79
+ expected_count=1,
80
+ public=True,
81
+ ),
82
+ RuleExample(
83
+ example_id="singleton-delegates",
84
+ title="Delegate the singleton read to the bulk implementation",
85
+ outcome=ExampleOutcome.NO_MATCH,
86
+ files=(
87
+ ExampleFile.python(
88
+ "app/user_store.py",
89
+ "class UserStore:\n"
90
+ " async def get(self, user_id: UserId) -> User | None:\n"
91
+ " rows = await self.get_many([user_id])\n"
92
+ " return rows[0] if rows else None\n\n"
93
+ " async def get_many(self, user_ids: list[UserId]) -> list[User]:\n"
94
+ " return await self.query_many(user_ids)\n",
95
+ ),
96
+ ),
97
+ focus_path=PurePosixPath("app/user_store.py"),
98
+ expected_count=0,
99
+ public=True,
100
+ ),
101
+ ),
102
+ )
103
+ description = documentation.summary
104
+
105
+ @override
106
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
107
+ if not is_store_module(path) or is_generated(path, source):
108
+ return []
109
+ tree = parse_or_none(path, source)
110
+ if tree is None:
111
+ return []
112
+ diagnostics: list[Diagnostic] = []
113
+ for statement in tree.body:
114
+ if not isinstance(statement, ast.ClassDef):
115
+ continue
116
+ pair = _compatible_pair(statement)
117
+ if pair is None:
118
+ continue
119
+ singleton, bulk = pair
120
+ if _has_branch(singleton) or _calls_method(singleton, bulk.name) or _calls_method(bulk, "get"):
121
+ continue
122
+ diagnostics.append(
123
+ Diagnostic(
124
+ path=path,
125
+ line=singleton.lineno,
126
+ col=singleton.col_offset + 1,
127
+ code=self.code,
128
+ message=(
129
+ f"This store defines compatible `get` and `{bulk.name}` methods; implement `get` through "
130
+ f"`{bulk.name}([key])` so singleton and bulk reads share one contract, or document why "
131
+ "their semantics differ."
132
+ ),
133
+ severity=Severity.WARNING,
134
+ )
135
+ )
136
+ return diagnostics
137
+
138
+
139
+ def _compatible_pair(
140
+ owner: ast.ClassDef,
141
+ ) -> _MethodPair | None:
142
+ methods = [node for node in owner.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))]
143
+ singletons = [node for node in methods if node.name == "get" and _is_concrete(node)]
144
+ bulks = [node for node in methods if node.name in _BULK_NAMES and _is_concrete(node)]
145
+ if len(singletons) != 1 or len(bulks) != 1:
146
+ return None
147
+ singleton, bulk = singletons[0], bulks[0]
148
+ if isinstance(singleton, ast.AsyncFunctionDef) is not isinstance(bulk, ast.AsyncFunctionDef):
149
+ return None
150
+ singleton_key = _single_key_annotation(singleton)
151
+ bulk_key = _bulk_key_annotation(bulk)
152
+ singleton_value = _nullable_value(singleton.returns)
153
+ bulk_types = _bulk_result_types(bulk.returns, bulk.name)
154
+ if singleton_key is None or bulk_key is None or singleton_value is None or bulk_types is None:
155
+ return None
156
+ result_key, result_value = bulk_types
157
+ if ast.dump(singleton_key, include_attributes=False) != ast.dump(bulk_key, include_attributes=False):
158
+ return None
159
+ if result_key is not None and ast.dump(singleton_key, include_attributes=False) != ast.dump(
160
+ result_key, include_attributes=False
161
+ ):
162
+ return None
163
+ if ast.dump(singleton_value, include_attributes=False) != ast.dump(result_value, include_attributes=False):
164
+ return None
165
+ return _MethodPair(singleton, bulk)
166
+
167
+
168
+ def _is_concrete(method: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
169
+ if any(
170
+ _qualified_name(decorator).split(".")[-1] in {"abstractmethod", "overload"}
171
+ for decorator in method.decorator_list
172
+ ):
173
+ return False
174
+ return not (
175
+ len(method.body) == 1
176
+ and (
177
+ isinstance(method.body[0], ast.Pass)
178
+ or (
179
+ isinstance(method.body[0], ast.Expr)
180
+ and isinstance(method.body[0].value, ast.Constant)
181
+ and method.body[0].value.value is Ellipsis
182
+ )
183
+ or isinstance(method.body[0], ast.Raise)
184
+ )
185
+ )
186
+
187
+
188
+ def _single_key_annotation(method: ast.FunctionDef | ast.AsyncFunctionDef) -> ast.expr | None:
189
+ if method.args.posonlyargs or method.args.vararg or method.args.kwarg or method.args.kwonlyargs:
190
+ return None
191
+ if len(method.args.args) != _PAIR_SIZE or method.args.defaults:
192
+ return None
193
+ self_arg, key_arg = method.args.args
194
+ if self_arg.arg not in {"self", "cls"}:
195
+ return None
196
+ return key_arg.annotation
197
+
198
+
199
+ def _bulk_key_annotation(method: ast.FunctionDef | ast.AsyncFunctionDef) -> ast.expr | None:
200
+ annotation = _single_key_annotation(method)
201
+ if isinstance(annotation, ast.Subscript) and _qualified_name(annotation.value) in {
202
+ "List",
203
+ "Sequence",
204
+ "list",
205
+ "typing.List",
206
+ "typing.Sequence",
207
+ }:
208
+ return annotation.slice
209
+ return None
210
+
211
+
212
+ def _nullable_value(annotation: ast.expr | None) -> ast.expr | None:
213
+ if isinstance(annotation, ast.Subscript) and _qualified_name(annotation.value) in {"Optional", "typing.Optional"}:
214
+ return annotation.slice
215
+ if isinstance(annotation, ast.BinOp) and isinstance(annotation.op, ast.BitOr):
216
+ if _is_none_annotation(annotation.left):
217
+ return annotation.right
218
+ if _is_none_annotation(annotation.right):
219
+ return annotation.left
220
+ return None
221
+
222
+
223
+ def _bulk_result_types(annotation: ast.expr | None, name: str) -> _BulkResultTypes | None:
224
+ if not isinstance(annotation, ast.Subscript):
225
+ return None
226
+ base = _qualified_name(annotation.value)
227
+ if name == "get_many" and base in {"List", "list", "typing.List"}:
228
+ return _BulkResultTypes(None, annotation.slice)
229
+ if (
230
+ name == "get_by_ids"
231
+ and base in {"Dict", "dict", "typing.Dict"}
232
+ and isinstance(annotation.slice, ast.Tuple)
233
+ and len(annotation.slice.elts) == _PAIR_SIZE
234
+ ):
235
+ return _BulkResultTypes(annotation.slice.elts[0], annotation.slice.elts[1])
236
+ return None
237
+
238
+
239
+ def _has_branch(method: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
240
+ return any(isinstance(node, _BRANCH_NODES) for node in _method_nodes(method))
241
+
242
+
243
+ def _calls_method(
244
+ method: ast.FunctionDef | ast.AsyncFunctionDef,
245
+ called_name: str,
246
+ ) -> bool:
247
+ for node in _method_nodes(method):
248
+ if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
249
+ continue
250
+ if (
251
+ not isinstance(node.func.value, ast.Name)
252
+ or node.func.value.id not in {"cls", "self"}
253
+ or node.func.attr != called_name
254
+ ):
255
+ continue
256
+ return True
257
+ return False
258
+
259
+
260
+ def _method_nodes(method: ast.FunctionDef | ast.AsyncFunctionDef) -> list[ast.AST]:
261
+ nodes: list[ast.AST] = []
262
+ stack: list[ast.AST] = [*reversed(method.body)]
263
+ while stack:
264
+ node = stack.pop()
265
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
266
+ continue
267
+ nodes.append(node)
268
+ stack.extend(reversed(list(ast.iter_child_nodes(node))))
269
+ return nodes
270
+
271
+
272
+ def _qualified_name(node: ast.expr) -> str:
273
+ if isinstance(node, ast.Name):
274
+ return node.id
275
+ if isinstance(node, ast.Attribute):
276
+ prefix = _qualified_name(node.value)
277
+ return f"{prefix}.{node.attr}" if prefix else node.attr
278
+ return ""
279
+
280
+
281
+ def _is_none_annotation(node: ast.expr) -> bool:
282
+ return isinstance(node, ast.Constant) and node.value is None
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from dataclasses import dataclass
5
+ from itertools import pairwise
6
+ from pathlib import PurePosixPath
7
+ from typing import TYPE_CHECKING, ClassVar, final, override
8
+
9
+ from sarj_python_lint.rule_base import (
10
+ AutofixPolicy,
11
+ Diagnostic,
12
+ ExampleFile,
13
+ ExampleOutcome,
14
+ Rule,
15
+ RuleCategory,
16
+ RuleDocumentation,
17
+ RuleExample,
18
+ Severity,
19
+ parse_or_none,
20
+ )
21
+ from sarj_python_lint.rules._paths import is_generated
22
+ from sarj_python_lint.rules._sql import is_store_module
23
+
24
+
25
+ if TYPE_CHECKING:
26
+ from pathlib import Path
27
+
28
+
29
+ @final
30
+ class PreferOneForRequiredRow(Rule):
31
+ id = "prefer-one-for-required-row"
32
+ code = "SARJ422"
33
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
34
+ summary="Use a required-row helper instead of asserting that `fetchone()` returned a row.",
35
+ rationale=(
36
+ "A bare assertion is removed by optimized Python and repeats the database contract at each call site; "
37
+ "a shared helper preserves the invariant and raises one intentional domain error."
38
+ ),
39
+ remediation=(
40
+ "Wrap the fetch in the repository's required-row helper, for example "
41
+ "`row = one(await cursor.fetchone())`, and remove the non-None assertion."
42
+ ),
43
+ category=RuleCategory.CORRECTNESS,
44
+ autofix=AutofixPolicy.NONE,
45
+ limitations=(
46
+ "Only an immediate exact `row is not None` assertion after `fetchone()` in production store modules is inspected.",
47
+ "The rule does not infer that an optional lookup must return a row and does not choose an import path for the helper.",
48
+ ),
49
+ examples=(
50
+ RuleExample(
51
+ example_id="required-returning-row",
52
+ title="Use the shared required-row contract",
53
+ outcome=ExampleOutcome.MATCH,
54
+ files=(
55
+ ExampleFile.python(
56
+ "app/settings_store.py",
57
+ "async def save(cursor):\n"
58
+ " row = await cursor.fetchone()\n"
59
+ " assert row is not None, 'RETURNING must yield a row'\n"
60
+ " return row\n",
61
+ ),
62
+ ),
63
+ focus_path=PurePosixPath("app/settings_store.py"),
64
+ expected_count=1,
65
+ public=True,
66
+ ),
67
+ RuleExample(
68
+ example_id="required-row-helper",
69
+ title="Centralize the missing-row failure",
70
+ outcome=ExampleOutcome.NO_MATCH,
71
+ files=(
72
+ ExampleFile.python(
73
+ "app/settings_store.py",
74
+ "async def save(cursor):\n return one(await cursor.fetchone())\n",
75
+ ),
76
+ ),
77
+ focus_path=PurePosixPath("app/settings_store.py"),
78
+ expected_count=0,
79
+ public=True,
80
+ ),
81
+ ),
82
+ )
83
+ description = documentation.summary
84
+
85
+ @override
86
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
87
+ if not is_store_module(path) or is_generated(path, source):
88
+ return []
89
+ tree = parse_or_none(path, source)
90
+ if tree is None:
91
+ return []
92
+ diagnostics: list[Diagnostic] = []
93
+ for owner in ast.walk(tree):
94
+ for statements in _statement_blocks(owner):
95
+ for assignment, assertion in pairwise(statements):
96
+ binding = _fetchone_binding(assignment)
97
+ if binding is None or not _asserts_not_none(assertion, binding.name):
98
+ continue
99
+ diagnostics.append(
100
+ Diagnostic(
101
+ path=path,
102
+ line=assignment.lineno,
103
+ col=assignment.col_offset + 1,
104
+ code=self.code,
105
+ message=(
106
+ f"`{binding.name}` is asserted non-None immediately after `fetchone()`. Use the shared "
107
+ f"required-row helper (for example `{binding.name} = one({binding.expression})`) so "
108
+ "optimized Python cannot remove the contract."
109
+ ),
110
+ severity=Severity.WARNING,
111
+ )
112
+ )
113
+ return diagnostics
114
+
115
+
116
+ def _statement_blocks(node: ast.AST) -> tuple[list[ast.stmt], ...]:
117
+ match node:
118
+ case ast.Module() | ast.FunctionDef() | ast.AsyncFunctionDef() | ast.ClassDef():
119
+ return (node.body,)
120
+ case ast.If() | ast.For() | ast.AsyncFor() | ast.While():
121
+ return node.body, node.orelse
122
+ case ast.With() | ast.AsyncWith() | ast.ExceptHandler() | ast.match_case():
123
+ return (node.body,)
124
+ case ast.Try() | ast.TryStar():
125
+ return node.body, node.orelse, node.finalbody, *(handler.body for handler in node.handlers)
126
+ case _:
127
+ return ()
128
+
129
+
130
+ @dataclass(frozen=True, slots=True)
131
+ class _FetchBinding:
132
+ name: str
133
+ expression: str
134
+
135
+
136
+ def _fetchone_binding(statement: ast.stmt) -> _FetchBinding | None:
137
+ target: ast.expr
138
+ value: ast.expr | None
139
+ if isinstance(statement, ast.Assign) and len(statement.targets) == 1:
140
+ target = statement.targets[0]
141
+ value = statement.value
142
+ elif isinstance(statement, ast.AnnAssign) and statement.value is not None:
143
+ target = statement.target
144
+ value = statement.value
145
+ else:
146
+ return None
147
+ if not isinstance(target, ast.Name):
148
+ return None
149
+ call = value.value if isinstance(value, ast.Await) else value
150
+ if not isinstance(call, ast.Call) or call.args or call.keywords:
151
+ return None
152
+ if not isinstance(call.func, ast.Attribute) or call.func.attr != "fetchone":
153
+ return None
154
+ return _FetchBinding(target.id, ast.unparse(value))
155
+
156
+
157
+ def _asserts_not_none(statement: ast.stmt, binding: str) -> bool:
158
+ if not isinstance(statement, ast.Assert) or not isinstance(statement.test, ast.Compare):
159
+ return False
160
+ comparison = statement.test
161
+ if len(comparison.ops) != 1 or not isinstance(comparison.ops[0], ast.IsNot) or len(comparison.comparators) != 1:
162
+ return False
163
+ left, right = comparison.left, comparison.comparators[0]
164
+ return (_is_name(left, binding) and _is_none(right)) or (_is_none(left) and _is_name(right, binding))
165
+
166
+
167
+ def _is_name(node: ast.expr, expected: str) -> bool:
168
+ return isinstance(node, ast.Name) and node.id == expected
169
+
170
+
171
+ def _is_none(node: ast.expr) -> bool:
172
+ return isinstance(node, ast.Constant) and node.value is None
173
+
174
+
175
+ __all__ = ["PreferOneForRequiredRow"]
@@ -1,121 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from pathlib import PurePosixPath
4
- import re
5
- from typing import TYPE_CHECKING, ClassVar, override
6
-
7
- from sarj_python_lint.rule_base import (
8
- ColumnEncoding,
9
- Diagnostic,
10
- ExampleFile,
11
- ExampleOutcome,
12
- Rule,
13
- RuleCategory,
14
- RuleDocumentation,
15
- RuleExample,
16
- )
17
- from sarj_python_lint.rules._suppression_comments import Comment, scan_comments_or_none
18
-
19
-
20
- if TYPE_CHECKING:
21
- from pathlib import Path
22
-
23
-
24
- # Match directive heads using each type checker's accepted spelling. Ruff's
25
- # file-level blanket is already owned by PGH004.
26
- _TYPE_IGNORE_RE = re.compile(r"^type:\s*ignore(?P<rest>.*)")
27
- _PYRIGHT_IGNORE_RE = re.compile(r"^pyright:\s*ignore(?P<rest>.*)")
28
-
29
- # Require a scoped rule list after directive heads that support one.
30
- _BRACKET_CODES_RE = re.compile(r"^\s*\[\s*\w")
31
-
32
- # `type: ignored` is a different word, not the directive.
33
- _WORD_CONTINUATION_RE = re.compile(r"^\w")
34
-
35
- _TYPE_IGNORE_MESSAGE = (
36
- "file-level `# type: ignore` silences every mypy error in this file, "
37
- "including ones added later — scope it (`# type: ignore[attr-defined]`) "
38
- "or fix the findings."
39
- )
40
- _PYRIGHT_IGNORE_MESSAGE = (
41
- "file-level `# pyright: ignore` silences every pyright diagnostic in this file, "
42
- "including ones added later — scope it (`# pyright: ignore[reportUnusedImport]`) "
43
- "or fix the findings."
44
- )
45
-
46
-
47
- class NoFileLevelSuppression(Rule):
48
- id: str = "no-file-level-suppression"
49
- code: str = "SARJ038"
50
- documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
51
- summary="Unscoped file-level type-checker suppressions hide every current and future diagnostic.",
52
- rationale="Mypy and Pyright blankets also hide diagnostics introduced by future tool upgrades.",
53
- remediation="Fix the findings or limit the directive to the specific diagnostic codes being suppressed.",
54
- category=RuleCategory.MAINTAINABILITY,
55
- limitations=(
56
- "Only mypy-compatible `type: ignore` and Pyright file-level directives are analyzed.",
57
- "Ruff's `# ruff: noqa` blanket is owned by Ruff PGH004.",
58
- "Trailing per-line suppressions and directives with an explicit code list are allowed.",
59
- ),
60
- examples=(
61
- RuleExample(
62
- example_id="unscoped-type-checker-suppression",
63
- title="Mypy disabled for the file",
64
- outcome=ExampleOutcome.MATCH,
65
- files=(ExampleFile.python("service.py", "# type: ignore\nimport os\n"),),
66
- focus_path=PurePosixPath("service.py"),
67
- expected_count=1,
68
- public=True,
69
- ),
70
- RuleExample(
71
- example_id="scoped-type-checker-suppression",
72
- title="Mypy suppression names its code",
73
- outcome=ExampleOutcome.NO_MATCH,
74
- files=(ExampleFile.python("service.py", "# type: ignore[import-not-found]\nimport os\n"),),
75
- focus_path=PurePosixPath("service.py"),
76
- expected_count=0,
77
- public=True,
78
- ),
79
- ),
80
- )
81
- description: str = documentation.summary
82
-
83
- @override
84
- def check(self, path: Path, source: str) -> list[Diagnostic]:
85
- comments = scan_comments_or_none(source)
86
- if comments is None:
87
- return []
88
- diags = [
89
- Diagnostic(
90
- path=path,
91
- line=comment.line,
92
- col=comment.col,
93
- code=self.code,
94
- message=message,
95
- column_encoding=ColumnEncoding.CODEPOINTS,
96
- )
97
- for comment in comments
98
- if (message := _blanket_message(comment)) is not None
99
- ]
100
- diags.sort(key=lambda d: (d.line, d.col))
101
- return diags
102
-
103
-
104
- def _blanket_message(comment: Comment) -> str | None:
105
- if not (comment.standalone and comment.before_first_statement):
106
- return None
107
- if _is_unscoped(comment.body, _TYPE_IGNORE_RE, _BRACKET_CODES_RE):
108
- return _TYPE_IGNORE_MESSAGE
109
- if _is_unscoped(comment.body, _PYRIGHT_IGNORE_RE, _BRACKET_CODES_RE):
110
- return _PYRIGHT_IGNORE_MESSAGE
111
- return None
112
-
113
-
114
- def _is_unscoped(body: str, directive: re.Pattern[str], codes: re.Pattern[str]) -> bool:
115
- match = directive.match(body)
116
- if match is None:
117
- return False
118
- rest = match["rest"]
119
- if _WORD_CONTINUATION_RE.match(rest):
120
- return False
121
- return not codes.match(rest)