sarj-python-lint 0.67.10__tar.gz → 0.67.12__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.
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/.gitignore +1 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/PKG-INFO +1 -1
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/pyproject.toml +1 -1
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_fastapi.py +31 -8
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_registry.py +14 -0
- sarj_python_lint-0.67.12/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +193 -0
- sarj_python_lint-0.67.12/src/sarj_python_lint/rules/no_apirouter_root_trailing_slash.py +120 -0
- sarj_python_lint-0.67.12/src/sarj_python_lint/rules/no_unique_violation_message_match.py +175 -0
- sarj_python_lint-0.67.12/src/sarj_python_lint/rules/source_coupled_test.py +357 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/LICENSE +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/README.md +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_imports.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
- {sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sarj-python-lint
|
|
3
|
-
Version: 0.67.
|
|
3
|
+
Version: 0.67.12
|
|
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/
|
{sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_fastapi.py
RENAMED
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import ast
|
|
6
6
|
from collections import Counter
|
|
7
7
|
from dataclasses import dataclass
|
|
8
|
-
from typing import TYPE_CHECKING, NamedTuple
|
|
8
|
+
from typing import TYPE_CHECKING, Literal, NamedTuple
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
if TYPE_CHECKING:
|
|
@@ -41,6 +41,7 @@ class Route:
|
|
|
41
41
|
receiver: str
|
|
42
42
|
method: str
|
|
43
43
|
path: str | None
|
|
44
|
+
receiver_kind: Literal["FastAPI", "APIRouter"]
|
|
44
45
|
inherited_hidden: bool = False
|
|
45
46
|
|
|
46
47
|
@property
|
|
@@ -114,6 +115,7 @@ class FastapiIndex:
|
|
|
114
115
|
self.type_aliases: dict[str, ast.expr] = {}
|
|
115
116
|
self.receivers: set[tuple[int, str]] = set()
|
|
116
117
|
self.receiver_origins: dict[tuple[int, str], tuple[int, str]] = {}
|
|
118
|
+
self.receiver_kinds: dict[tuple[int, str], Literal["FastAPI", "APIRouter"]] = {}
|
|
117
119
|
self.hidden_receivers: set[tuple[int, str]] = set()
|
|
118
120
|
self.decorators: dict[tuple[int, str], tuple[str, str]] = {}
|
|
119
121
|
self.bound_names: set[tuple[int, str]] = set()
|
|
@@ -228,10 +230,20 @@ class FastapiIndex:
|
|
|
228
230
|
if key in self.receivers or key in self.decorators:
|
|
229
231
|
continue
|
|
230
232
|
source = self._receiver_key(scope, _binding_name(value))
|
|
231
|
-
|
|
233
|
+
constructor_kind = self._constructor_kind(value)
|
|
234
|
+
if constructor_kind is not None:
|
|
232
235
|
self.receivers.add(key)
|
|
233
|
-
self.receiver_origins[key] = key
|
|
234
|
-
|
|
236
|
+
self.receiver_origins[key] = key
|
|
237
|
+
self.receiver_kinds[key] = constructor_kind
|
|
238
|
+
if self._constructor_is_hidden(value):
|
|
239
|
+
self.hidden_receivers.add(key)
|
|
240
|
+
changed = True
|
|
241
|
+
continue
|
|
242
|
+
if source is not None:
|
|
243
|
+
self.receivers.add(key)
|
|
244
|
+
self.receiver_origins[key] = self.receiver_origins[source]
|
|
245
|
+
self.receiver_kinds[key] = self.receiver_kinds[source]
|
|
246
|
+
if source in self.hidden_receivers:
|
|
235
247
|
self.hidden_receivers.add(key)
|
|
236
248
|
changed = True
|
|
237
249
|
continue
|
|
@@ -276,16 +288,26 @@ class FastapiIndex:
|
|
|
276
288
|
return isinstance(value, ast.Constant) and value.value is False
|
|
277
289
|
|
|
278
290
|
def _is_constructor_call(self, node: ast.expr) -> bool:
|
|
291
|
+
return self._constructor_kind(node) is not None
|
|
292
|
+
|
|
293
|
+
def _constructor_kind(self, node: ast.expr) -> Literal["FastAPI", "APIRouter"] | None:
|
|
279
294
|
if not isinstance(node, ast.Call):
|
|
280
|
-
return
|
|
295
|
+
return None
|
|
281
296
|
if isinstance(node.func, ast.Name):
|
|
282
|
-
|
|
283
|
-
|
|
297
|
+
canonical = self.symbols.get(node.func.id)
|
|
298
|
+
if canonical == "FastAPI":
|
|
299
|
+
return "FastAPI"
|
|
300
|
+
if canonical == "APIRouter":
|
|
301
|
+
return "APIRouter"
|
|
302
|
+
return None
|
|
303
|
+
if (
|
|
284
304
|
isinstance(node.func, ast.Attribute)
|
|
285
305
|
and isinstance(node.func.value, ast.Name)
|
|
286
306
|
and node.func.value.id in self.modules
|
|
287
307
|
and node.func.attr in {"FastAPI", "APIRouter"}
|
|
288
|
-
)
|
|
308
|
+
):
|
|
309
|
+
return "FastAPI" if node.func.attr == "FastAPI" else "APIRouter"
|
|
310
|
+
return None
|
|
289
311
|
|
|
290
312
|
def routes(self, function: ast.FunctionDef | ast.AsyncFunctionDef) -> tuple[Route, ...]:
|
|
291
313
|
routes: list[Route] = []
|
|
@@ -321,6 +343,7 @@ class FastapiIndex:
|
|
|
321
343
|
receiver=f"{origin_scope}:{origin_name}",
|
|
322
344
|
method=route_method,
|
|
323
345
|
path=path,
|
|
346
|
+
receiver_kind=self.receiver_kinds[receiver_key],
|
|
324
347
|
inherited_hidden=receiver_key in self.hidden_receivers,
|
|
325
348
|
)
|
|
326
349
|
for route_method in route_methods
|
{sarj_python_lint-0.67.10 → sarj_python_lint-0.67.12}/src/sarj_python_lint/rules/_registry.py
RENAMED
|
@@ -3,6 +3,9 @@ from __future__ import annotations
|
|
|
3
3
|
from types import MappingProxyType
|
|
4
4
|
from typing import TYPE_CHECKING
|
|
5
5
|
|
|
6
|
+
from sarj_python_lint.rules.created_at_order_requires_tiebreaker import (
|
|
7
|
+
CreatedAtOrderRequiresTiebreaker,
|
|
8
|
+
)
|
|
6
9
|
from sarj_python_lint.rules.defect_xfail_requires_strict import DefectXfailRequiresStrict
|
|
7
10
|
from sarj_python_lint.rules.docstring_args_restate_signature import (
|
|
8
11
|
DocstringArgsRestateSignature,
|
|
@@ -24,6 +27,9 @@ from sarj_python_lint.rules.mock_without_spec import MockWithoutSpec
|
|
|
24
27
|
from sarj_python_lint.rules.no_aggregation_in_store_query import (
|
|
25
28
|
NoAggregationInStoreQuery,
|
|
26
29
|
)
|
|
30
|
+
from sarj_python_lint.rules.no_apirouter_root_trailing_slash import (
|
|
31
|
+
NoApirouterRootTrailingSlash,
|
|
32
|
+
)
|
|
27
33
|
from sarj_python_lint.rules.no_comment_cruft import NoCommentCruft
|
|
28
34
|
from sarj_python_lint.rules.no_cors_wildcard_with_credentials import (
|
|
29
35
|
NoCorsWildcardWithCredentials,
|
|
@@ -58,6 +64,9 @@ from sarj_python_lint.rules.no_stdlib_logging import NoStdlibLogging
|
|
|
58
64
|
from sarj_python_lint.rules.no_string_concat_in_loop import NoStringConcatInLoop
|
|
59
65
|
from sarj_python_lint.rules.no_tautological_expect import NoTautologicalExpect
|
|
60
66
|
from sarj_python_lint.rules.no_typed_doc_sections import NoTypedDocSections
|
|
67
|
+
from sarj_python_lint.rules.no_unique_violation_message_match import (
|
|
68
|
+
NoUniqueViolationMessageMatch,
|
|
69
|
+
)
|
|
61
70
|
from sarj_python_lint.rules.opaque_parametrize_case_needs_id import OpaqueParametrizeCaseNeedsId
|
|
62
71
|
from sarj_python_lint.rules.over_mocked_test import OverMockedTest
|
|
63
72
|
from sarj_python_lint.rules.phase_label_comment import TestPhaseLabelComment
|
|
@@ -108,6 +117,7 @@ from sarj_python_lint.rules.require_keyword_only_swap_prone_params import (
|
|
|
108
117
|
)
|
|
109
118
|
from sarj_python_lint.rules.require_port_for_service import RequirePortForService
|
|
110
119
|
from sarj_python_lint.rules.restated_test_docstring import RestatedTestDocstring
|
|
120
|
+
from sarj_python_lint.rules.source_coupled_test import SourceCoupledTest
|
|
111
121
|
from sarj_python_lint.rules.stepdown import Stepdown
|
|
112
122
|
from sarj_python_lint.rules.store_insert_requires_on_conflict import (
|
|
113
123
|
StoreInsertRequiresOnConflict,
|
|
@@ -124,6 +134,7 @@ if TYPE_CHECKING:
|
|
|
124
134
|
|
|
125
135
|
REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
126
136
|
{
|
|
137
|
+
CreatedAtOrderRequiresTiebreaker.id: CreatedAtOrderRequiresTiebreaker,
|
|
127
138
|
NoStringConcatInLoop.id: NoStringConcatInLoop,
|
|
128
139
|
PreferClassRow.id: PreferClassRow,
|
|
129
140
|
PreferStrEnum.id: PreferStrEnum,
|
|
@@ -140,8 +151,10 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
|
140
151
|
PreferStructOverNamedtuple.id: PreferStructOverNamedtuple,
|
|
141
152
|
NoCommentCruft.id: NoCommentCruft,
|
|
142
153
|
StoreInsertRequiresOnConflict.id: StoreInsertRequiresOnConflict,
|
|
154
|
+
SourceCoupledTest.id: SourceCoupledTest,
|
|
143
155
|
NoQueryWithManyJoins.id: NoQueryWithManyJoins,
|
|
144
156
|
NoAggregationInStoreQuery.id: NoAggregationInStoreQuery,
|
|
157
|
+
NoApirouterRootTrailingSlash.id: NoApirouterRootTrailingSlash,
|
|
145
158
|
NoSelectStar.id: NoSelectStar,
|
|
146
159
|
NoGenericSingleExportModule.id: NoGenericSingleExportModule,
|
|
147
160
|
Stepdown.id: Stepdown,
|
|
@@ -194,6 +207,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
|
194
207
|
NoLongComment.id: NoLongComment,
|
|
195
208
|
NoTypedDocSections.id: NoTypedDocSections,
|
|
196
209
|
PreferNominalIdTypes.id: PreferNominalIdTypes,
|
|
210
|
+
NoUniqueViolationMessageMatch.id: NoUniqueViolationMessageMatch,
|
|
197
211
|
}
|
|
198
212
|
)
|
|
199
213
|
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""SARJ407 — Store queries ordered by `created_at` need a later tie-break key.
|
|
2
|
+
|
|
3
|
+
Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_created_at_order_requires_tiebreaker.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
from pathlib import PurePosixPath
|
|
10
|
+
import re
|
|
11
|
+
from typing import TYPE_CHECKING, final, override
|
|
12
|
+
|
|
13
|
+
from sarj_python_lint.rule_base import (
|
|
14
|
+
AutofixPolicy,
|
|
15
|
+
Diagnostic,
|
|
16
|
+
ExampleFile,
|
|
17
|
+
ExampleOutcome,
|
|
18
|
+
Rule,
|
|
19
|
+
RuleCategory,
|
|
20
|
+
RuleDocumentation,
|
|
21
|
+
RuleExample,
|
|
22
|
+
Severity,
|
|
23
|
+
parse_or_none,
|
|
24
|
+
)
|
|
25
|
+
from sarj_python_lint.rules._ast_index import nodes, walk
|
|
26
|
+
from sarj_python_lint.rules._paths import is_generated
|
|
27
|
+
from sarj_python_lint.rules._sql import is_store_module, sql_string_value, strip_sql_noise
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_QUERY_SHAPE = re.compile(r"\bSELECT\b[\s\S]*?\bFROM\b", re.IGNORECASE)
|
|
35
|
+
_ORDER_BY = re.compile(r"\bORDER\s+BY\b", re.IGNORECASE)
|
|
36
|
+
_CLAUSE_BOUNDARY = re.compile(
|
|
37
|
+
r"\b(?:LIMIT|OFFSET|FETCH|FOR|UNION|INTERSECT|EXCEPT|RETURNING|WINDOW|QUALIFY|"
|
|
38
|
+
r"GROUP\s+BY|HAVING|ORDER\s+BY)\b",
|
|
39
|
+
re.IGNORECASE,
|
|
40
|
+
)
|
|
41
|
+
_CREATED_AT_ITEM = re.compile(
|
|
42
|
+
r"(?:(?:[A-Za-z_][A-Za-z0-9_$]*\s*\.\s*)*)created_at"
|
|
43
|
+
r"(?:\s+(?:ASC|DESC))?(?:\s+NULLS\s+(?:FIRST|LAST))?\s*\Z",
|
|
44
|
+
re.IGNORECASE,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@final
|
|
49
|
+
class CreatedAtOrderRequiresTiebreaker(Rule):
|
|
50
|
+
id = "created-at-order-requires-tiebreaker"
|
|
51
|
+
code = "SARJ407"
|
|
52
|
+
documentation = RuleDocumentation(
|
|
53
|
+
summary="Store queries ordered by `created_at` should include a later tie-break key.",
|
|
54
|
+
rationale=(
|
|
55
|
+
"Timestamps are not unique, so rows with the same `created_at` value have no stable relative order. "
|
|
56
|
+
"That can make pagination and repeated reads skip, repeat, or reorder rows."
|
|
57
|
+
),
|
|
58
|
+
remediation="Add a stable key after `created_at`, such as `ORDER BY created_at DESC, id DESC`.",
|
|
59
|
+
category=RuleCategory.CORRECTNESS,
|
|
60
|
+
autofix=AutofixPolicy.NONE,
|
|
61
|
+
limitations=(
|
|
62
|
+
"Only fully reconstructable SQL string literals in recognized production store modules are analyzed.",
|
|
63
|
+
(
|
|
64
|
+
"The rule reports an exact, optionally qualified `created_at` column only when it is the final "
|
|
65
|
+
"same-depth `ORDER BY` item; any later same-depth ordering item is accepted."
|
|
66
|
+
),
|
|
67
|
+
(
|
|
68
|
+
"Dynamic string construction, formatted strings, quoted identifiers, expressions containing "
|
|
69
|
+
"`created_at`, and non-SELECT fragments are excluded."
|
|
70
|
+
),
|
|
71
|
+
"The intended stable key cannot be inferred safely, so the rule does not offer an autofix.",
|
|
72
|
+
),
|
|
73
|
+
examples=(
|
|
74
|
+
RuleExample(
|
|
75
|
+
example_id="created-at-is-the-only-order-key",
|
|
76
|
+
title="A store query orders only by a timestamp",
|
|
77
|
+
outcome=ExampleOutcome.MATCH,
|
|
78
|
+
files=(
|
|
79
|
+
ExampleFile.python(
|
|
80
|
+
"app/task_store.py",
|
|
81
|
+
'QUERY = "SELECT id, created_at FROM task ORDER BY created_at DESC LIMIT 50"\n',
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
focus_path=PurePosixPath("app/task_store.py"),
|
|
85
|
+
expected_count=1,
|
|
86
|
+
public=True,
|
|
87
|
+
),
|
|
88
|
+
RuleExample(
|
|
89
|
+
example_id="created-at-is-followed-by-id",
|
|
90
|
+
title="A stable key breaks equal-timestamp ties",
|
|
91
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
92
|
+
files=(
|
|
93
|
+
ExampleFile.python(
|
|
94
|
+
"app/task_store.py",
|
|
95
|
+
'QUERY = "SELECT id, created_at FROM task ORDER BY created_at DESC, id DESC LIMIT 50"\n',
|
|
96
|
+
),
|
|
97
|
+
),
|
|
98
|
+
focus_path=PurePosixPath("app/task_store.py"),
|
|
99
|
+
expected_count=0,
|
|
100
|
+
public=True,
|
|
101
|
+
),
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
description = documentation.summary
|
|
105
|
+
|
|
106
|
+
@override
|
|
107
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
108
|
+
if not is_store_module(path) or is_generated(path, source):
|
|
109
|
+
return []
|
|
110
|
+
tree = parse_or_none(path, source)
|
|
111
|
+
if tree is None:
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
diagnostics: list[Diagnostic] = []
|
|
115
|
+
consumed: set[int] = set()
|
|
116
|
+
for node in nodes(tree, ast.Constant, ast.BinOp, ast.JoinedStr):
|
|
117
|
+
if id(node) in consumed:
|
|
118
|
+
continue
|
|
119
|
+
if isinstance(node, (ast.BinOp, ast.JoinedStr)):
|
|
120
|
+
consumed.update(id(child) for child in walk(node))
|
|
121
|
+
if not _is_fully_static_string(node):
|
|
122
|
+
continue
|
|
123
|
+
text = sql_string_value(node)
|
|
124
|
+
if text is None:
|
|
125
|
+
continue
|
|
126
|
+
sql = strip_sql_noise(text)
|
|
127
|
+
if _QUERY_SHAPE.search(sql) is None or not _created_at_ends_order_clause(sql):
|
|
128
|
+
continue
|
|
129
|
+
diagnostics.append(
|
|
130
|
+
Diagnostic(
|
|
131
|
+
path=path,
|
|
132
|
+
line=node.lineno,
|
|
133
|
+
col=node.col_offset + 1,
|
|
134
|
+
code=self.code,
|
|
135
|
+
severity=Severity.WARNING,
|
|
136
|
+
message=(
|
|
137
|
+
"Store query leaves `created_at` as its final ordering item; add a stable tie-break key "
|
|
138
|
+
"after it, such as `id` (for example, `ORDER BY created_at DESC, id DESC`)."
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
diagnostics.sort(key=lambda diagnostic: (diagnostic.line, diagnostic.col))
|
|
143
|
+
return diagnostics
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _is_fully_static_string(node: ast.expr) -> bool:
|
|
147
|
+
if isinstance(node, ast.Constant):
|
|
148
|
+
return isinstance(node.value, str)
|
|
149
|
+
return (
|
|
150
|
+
isinstance(node, ast.BinOp)
|
|
151
|
+
and isinstance(node.op, ast.Add)
|
|
152
|
+
and _is_fully_static_string(node.left)
|
|
153
|
+
and _is_fully_static_string(node.right)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _created_at_ends_order_clause(sql: str) -> bool:
|
|
158
|
+
depths = _depths(sql)
|
|
159
|
+
for order in _ORDER_BY.finditer(sql):
|
|
160
|
+
clause_depth = depths[order.start()]
|
|
161
|
+
item_start = order.end()
|
|
162
|
+
clause_end = len(sql)
|
|
163
|
+
index = item_start
|
|
164
|
+
while index < len(sql):
|
|
165
|
+
if (
|
|
166
|
+
depths[index] < clause_depth
|
|
167
|
+
or sql[index] == ";"
|
|
168
|
+
or (sql[index] == ")" and depths[index] == clause_depth)
|
|
169
|
+
):
|
|
170
|
+
clause_end = index
|
|
171
|
+
break
|
|
172
|
+
if depths[index] == clause_depth:
|
|
173
|
+
if sql[index] == ",":
|
|
174
|
+
item_start = index + 1
|
|
175
|
+
elif _CLAUSE_BOUNDARY.match(sql, index) is not None:
|
|
176
|
+
clause_end = index
|
|
177
|
+
break
|
|
178
|
+
index += 1
|
|
179
|
+
if _CREATED_AT_ITEM.fullmatch(sql[item_start:clause_end].strip()) is not None:
|
|
180
|
+
return True
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _depths(sql: str) -> list[int]:
|
|
185
|
+
depths: list[int] = []
|
|
186
|
+
depth = 0
|
|
187
|
+
for character in sql:
|
|
188
|
+
depths.append(depth)
|
|
189
|
+
if character == "(":
|
|
190
|
+
depth += 1
|
|
191
|
+
elif character == ")" and depth:
|
|
192
|
+
depth -= 1
|
|
193
|
+
return depths
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""SARJ405 — APIRouter collection roots use an empty path, not ``/``.
|
|
2
|
+
|
|
3
|
+
Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_no_apirouter_root_trailing_slash.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
from pathlib import PurePosixPath
|
|
10
|
+
from typing import TYPE_CHECKING, ClassVar, final, override
|
|
11
|
+
|
|
12
|
+
from sarj_python_lint.rule_base import (
|
|
13
|
+
AutofixPolicy,
|
|
14
|
+
Diagnostic,
|
|
15
|
+
ExampleFile,
|
|
16
|
+
ExampleOutcome,
|
|
17
|
+
Rule,
|
|
18
|
+
RuleCategory,
|
|
19
|
+
RuleDocumentation,
|
|
20
|
+
RuleExample,
|
|
21
|
+
Severity,
|
|
22
|
+
parse_or_none,
|
|
23
|
+
)
|
|
24
|
+
from sarj_python_lint.rules._ast_index import nodes
|
|
25
|
+
from sarj_python_lint.rules._fastapi import FastapiIndex
|
|
26
|
+
from sarj_python_lint.rules._paths import is_generated, is_test_path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_DOCUMENTATION_EXAMPLE_DIR_NAMES = frozenset({"docs_src"})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@final
|
|
37
|
+
class NoApirouterRootTrailingSlash(Rule):
|
|
38
|
+
id: str = "no-apirouter-root-trailing-slash"
|
|
39
|
+
code: str = "SARJ405"
|
|
40
|
+
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
41
|
+
summary="APIRouter collection-root operations should use an empty path instead of `/`.",
|
|
42
|
+
rationale=(
|
|
43
|
+
"A `/` path beneath an APIRouter prefix makes the prefix without its trailing slash redirect, "
|
|
44
|
+
"rather than serving the canonical collection URL directly."
|
|
45
|
+
),
|
|
46
|
+
remediation="Declare the APIRouter operation path as an empty string so the prefix itself is canonical.",
|
|
47
|
+
category=RuleCategory.CORRECTNESS,
|
|
48
|
+
autofix=AutofixPolicy.NONE,
|
|
49
|
+
limitations=(
|
|
50
|
+
"Only FastAPI and APIRouter bindings whose constructor provenance is visible in one module are resolved.",
|
|
51
|
+
"Dynamic route paths are ignored because their runtime value cannot be proven.",
|
|
52
|
+
"Tests, generated sources, and FastAPI documentation-source examples are excluded.",
|
|
53
|
+
),
|
|
54
|
+
examples=(
|
|
55
|
+
RuleExample(
|
|
56
|
+
example_id="router-root-trailing-slash",
|
|
57
|
+
title="APIRouter root operation adds a trailing slash",
|
|
58
|
+
outcome=ExampleOutcome.MATCH,
|
|
59
|
+
files=(
|
|
60
|
+
ExampleFile.python(
|
|
61
|
+
"api.py",
|
|
62
|
+
"from fastapi import APIRouter\n\nrouter = APIRouter(prefix='/items')\n\n"
|
|
63
|
+
"@router.get('/')\nasync def list_items() -> list[str]:\n return []\n",
|
|
64
|
+
),
|
|
65
|
+
),
|
|
66
|
+
focus_path=PurePosixPath("api.py"),
|
|
67
|
+
expected_count=1,
|
|
68
|
+
public=True,
|
|
69
|
+
),
|
|
70
|
+
RuleExample(
|
|
71
|
+
example_id="canonical-router-root",
|
|
72
|
+
title="APIRouter root operation uses its prefix directly",
|
|
73
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
74
|
+
files=(
|
|
75
|
+
ExampleFile.python(
|
|
76
|
+
"api.py",
|
|
77
|
+
"from fastapi import APIRouter\n\nrouter = APIRouter(prefix='/items')\n\n"
|
|
78
|
+
"@router.get('')\nasync def list_items() -> list[str]:\n return []\n",
|
|
79
|
+
),
|
|
80
|
+
),
|
|
81
|
+
focus_path=PurePosixPath("api.py"),
|
|
82
|
+
expected_count=0,
|
|
83
|
+
public=True,
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
description: str = documentation.summary
|
|
88
|
+
|
|
89
|
+
@override
|
|
90
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
91
|
+
if (
|
|
92
|
+
is_test_path(path)
|
|
93
|
+
or is_generated(path, source)
|
|
94
|
+
or any(part.lower() in _DOCUMENTATION_EXAMPLE_DIR_NAMES for part in path.parts)
|
|
95
|
+
):
|
|
96
|
+
return []
|
|
97
|
+
tree = parse_or_none(path, source)
|
|
98
|
+
if tree is None:
|
|
99
|
+
return []
|
|
100
|
+
index = FastapiIndex(tree, path=path)
|
|
101
|
+
decorators: dict[int, ast.Call] = {}
|
|
102
|
+
for function in nodes(tree, ast.FunctionDef, ast.AsyncFunctionDef):
|
|
103
|
+
for route in index.routes(function):
|
|
104
|
+
if route.receiver_kind == "APIRouter" and route.path == "/":
|
|
105
|
+
decorators[id(route.decorator)] = route.decorator
|
|
106
|
+
|
|
107
|
+
return [
|
|
108
|
+
Diagnostic(
|
|
109
|
+
path=path,
|
|
110
|
+
line=decorator.lineno,
|
|
111
|
+
col=decorator.col_offset + 1,
|
|
112
|
+
code=self.code,
|
|
113
|
+
severity=Severity.WARNING,
|
|
114
|
+
message=(
|
|
115
|
+
"APIRouter collection-root path is `/`, so the prefix without a slash redirects — "
|
|
116
|
+
"declare the path as an empty string. Suppress with `# sarj-noqa: SARJ405`."
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
for decorator in sorted(decorators.values(), key=lambda node: (node.lineno, node.col_offset))
|
|
120
|
+
]
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""SARJ404 — Classify unique violations by structured constraint identity.
|
|
2
|
+
|
|
3
|
+
Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_no_unique_violation_message_match.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
from pathlib import PurePosixPath
|
|
10
|
+
from typing import TYPE_CHECKING, ClassVar, final, override
|
|
11
|
+
|
|
12
|
+
from sarj_python_lint.rule_base import (
|
|
13
|
+
AutofixPolicy,
|
|
14
|
+
Diagnostic,
|
|
15
|
+
ExampleFile,
|
|
16
|
+
ExampleOutcome,
|
|
17
|
+
Rule,
|
|
18
|
+
RuleCategory,
|
|
19
|
+
RuleDocumentation,
|
|
20
|
+
RuleExample,
|
|
21
|
+
Severity,
|
|
22
|
+
parse_or_none,
|
|
23
|
+
)
|
|
24
|
+
from sarj_python_lint.rules._imports import ImportIndex
|
|
25
|
+
from sarj_python_lint.rules._paths import is_generated, is_test_path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_DRIVERS = frozenset({"psycopg", "psycopg2"})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@final
|
|
36
|
+
class NoUniqueViolationMessageMatch(Rule):
|
|
37
|
+
id = "no-unique-violation-message-match"
|
|
38
|
+
code = "SARJ404"
|
|
39
|
+
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
40
|
+
summary="Do not identify a database unique constraint by substring-matching an exception message.",
|
|
41
|
+
rationale=(
|
|
42
|
+
"Database error text is not a stable interface and can change with driver or server versions; "
|
|
43
|
+
"psycopg exposes the violated constraint as structured diagnostic data."
|
|
44
|
+
),
|
|
45
|
+
remediation="Compare `exc.diag.constraint_name` with the expected constraint name.",
|
|
46
|
+
category=RuleCategory.CORRECTNESS,
|
|
47
|
+
autofix=AutofixPolicy.NONE,
|
|
48
|
+
limitations=(
|
|
49
|
+
"The rule recognizes psycopg and psycopg2 `UniqueViolation` handlers with direct `in` or `not in` checks against `str(exc)`.",
|
|
50
|
+
"Aliases of the stringified exception and other message operations remain outside its scope.",
|
|
51
|
+
"Tests and generated files are excluded.",
|
|
52
|
+
),
|
|
53
|
+
examples=(
|
|
54
|
+
RuleExample(
|
|
55
|
+
example_id="message-matched-constraint",
|
|
56
|
+
title="Unique constraint selected from error text",
|
|
57
|
+
outcome=ExampleOutcome.MATCH,
|
|
58
|
+
files=(
|
|
59
|
+
ExampleFile.python(
|
|
60
|
+
"app/store.py",
|
|
61
|
+
"from psycopg import errors\n\ntry:\n save()\nexcept errors.UniqueViolation as exc:\n if 'user_email_key' in str(exc):\n raise DuplicateEmail from exc\n raise\n",
|
|
62
|
+
),
|
|
63
|
+
),
|
|
64
|
+
focus_path=PurePosixPath("app/store.py"),
|
|
65
|
+
expected_count=1,
|
|
66
|
+
public=True,
|
|
67
|
+
),
|
|
68
|
+
RuleExample(
|
|
69
|
+
example_id="structured-constraint-check",
|
|
70
|
+
title="Unique constraint selected from structured diagnostics",
|
|
71
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
72
|
+
files=(
|
|
73
|
+
ExampleFile.python(
|
|
74
|
+
"app/store.py",
|
|
75
|
+
"from psycopg import errors\n\ntry:\n save()\nexcept errors.UniqueViolation as exc:\n if exc.diag.constraint_name == 'user_email_key':\n raise DuplicateEmail from exc\n raise\n",
|
|
76
|
+
),
|
|
77
|
+
),
|
|
78
|
+
focus_path=PurePosixPath("app/store.py"),
|
|
79
|
+
expected_count=0,
|
|
80
|
+
public=True,
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
description = documentation.summary
|
|
85
|
+
|
|
86
|
+
@override
|
|
87
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
88
|
+
if is_test_path(path) or is_generated(path, source) or "UniqueViolation" not in source:
|
|
89
|
+
return []
|
|
90
|
+
tree = parse_or_none(path, source)
|
|
91
|
+
if tree is None:
|
|
92
|
+
return []
|
|
93
|
+
imports = ImportIndex.from_tree(tree)
|
|
94
|
+
if not imports.builtin_is_unshadowed("str"):
|
|
95
|
+
return []
|
|
96
|
+
findings: list[ast.Call] = []
|
|
97
|
+
for handler in (node for node in ast.walk(tree) if isinstance(node, ast.ExceptHandler)):
|
|
98
|
+
if handler.name is None or not _catches_unique_violation(handler.type, tree):
|
|
99
|
+
continue
|
|
100
|
+
for statement in handler.body:
|
|
101
|
+
_collect_message_matches(statement, handler.name, findings)
|
|
102
|
+
findings.sort(key=lambda node: (node.lineno, node.col_offset))
|
|
103
|
+
return [
|
|
104
|
+
Diagnostic(
|
|
105
|
+
path=path,
|
|
106
|
+
line=node.lineno,
|
|
107
|
+
col=node.col_offset + 1,
|
|
108
|
+
code=self.code,
|
|
109
|
+
message=(
|
|
110
|
+
"UniqueViolation is classified by substring-matching its message; compare "
|
|
111
|
+
"`exc.diag.constraint_name` with the expected constraint instead."
|
|
112
|
+
),
|
|
113
|
+
severity=Severity.WARNING,
|
|
114
|
+
)
|
|
115
|
+
for node in findings
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _catches_unique_violation(node: ast.expr | None, tree: ast.Module) -> bool:
|
|
120
|
+
if node is None:
|
|
121
|
+
return False
|
|
122
|
+
candidates = node.elts if isinstance(node, ast.Tuple) else (node,)
|
|
123
|
+
return any(
|
|
124
|
+
_qualified_import(candidate, tree) in {"psycopg.errors.UniqueViolation", "psycopg2.errors.UniqueViolation"}
|
|
125
|
+
for candidate in candidates
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _qualified_import(node: ast.expr, tree: ast.Module) -> str | None:
|
|
130
|
+
parts: list[str] = []
|
|
131
|
+
current = node
|
|
132
|
+
while isinstance(current, ast.Attribute):
|
|
133
|
+
parts.append(current.attr)
|
|
134
|
+
current = current.value
|
|
135
|
+
if not isinstance(current, ast.Name):
|
|
136
|
+
return None
|
|
137
|
+
parts.reverse()
|
|
138
|
+
for statement in tree.body:
|
|
139
|
+
if isinstance(statement, ast.Import):
|
|
140
|
+
for alias in statement.names:
|
|
141
|
+
local = alias.asname or alias.name.split(".")[0]
|
|
142
|
+
if local == current.id:
|
|
143
|
+
base = alias.name if alias.asname else alias.name.split(".")[0]
|
|
144
|
+
return ".".join((base, *parts))
|
|
145
|
+
elif isinstance(statement, ast.ImportFrom) and statement.level == 0 and statement.module:
|
|
146
|
+
for alias in statement.names:
|
|
147
|
+
if (alias.asname or alias.name) != current.id:
|
|
148
|
+
continue
|
|
149
|
+
return ".".join((statement.module, alias.name, *parts))
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _collect_message_matches(node: ast.AST, exception_name: str, findings: list[ast.Call]) -> None:
|
|
154
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
|
|
155
|
+
return
|
|
156
|
+
if isinstance(node, ast.Compare) and any(isinstance(operator, (ast.In, ast.NotIn)) for operator in node.ops):
|
|
157
|
+
findings.extend(
|
|
158
|
+
operand
|
|
159
|
+
for operand in (node.left, *node.comparators)
|
|
160
|
+
if isinstance(operand, ast.Call) and _is_str_exception_call(operand, exception_name)
|
|
161
|
+
)
|
|
162
|
+
for child in ast.iter_child_nodes(node):
|
|
163
|
+
_collect_message_matches(child, exception_name, findings)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _is_str_exception_call(node: ast.expr, exception_name: str) -> bool:
|
|
167
|
+
return (
|
|
168
|
+
isinstance(node, ast.Call)
|
|
169
|
+
and isinstance(node.func, ast.Name)
|
|
170
|
+
and node.func.id == "str"
|
|
171
|
+
and len(node.args) == 1
|
|
172
|
+
and not node.keywords
|
|
173
|
+
and isinstance(node.args[0], ast.Name)
|
|
174
|
+
and node.args[0].id == exception_name
|
|
175
|
+
)
|