sarj-python-lint 0.67.11__tar.gz → 0.67.13__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.11 → sarj_python_lint-0.67.13}/.gitignore +1 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/PKG-INFO +1 -1
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/pyproject.toml +1 -1
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/_fastapi.py +31 -8
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/_registry.py +8 -0
- sarj_python_lint-0.67.13/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +193 -0
- sarj_python_lint-0.67.13/src/sarj_python_lint/rules/no_unique_violation_message_match.py +175 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/LICENSE +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/README.md +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/_imports.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/source_coupled_test.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
- {sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/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.13
|
|
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.11 → sarj_python_lint-0.67.13}/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.11 → sarj_python_lint-0.67.13}/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,
|
|
@@ -58,6 +61,9 @@ from sarj_python_lint.rules.no_stdlib_logging import NoStdlibLogging
|
|
|
58
61
|
from sarj_python_lint.rules.no_string_concat_in_loop import NoStringConcatInLoop
|
|
59
62
|
from sarj_python_lint.rules.no_tautological_expect import NoTautologicalExpect
|
|
60
63
|
from sarj_python_lint.rules.no_typed_doc_sections import NoTypedDocSections
|
|
64
|
+
from sarj_python_lint.rules.no_unique_violation_message_match import (
|
|
65
|
+
NoUniqueViolationMessageMatch,
|
|
66
|
+
)
|
|
61
67
|
from sarj_python_lint.rules.opaque_parametrize_case_needs_id import OpaqueParametrizeCaseNeedsId
|
|
62
68
|
from sarj_python_lint.rules.over_mocked_test import OverMockedTest
|
|
63
69
|
from sarj_python_lint.rules.phase_label_comment import TestPhaseLabelComment
|
|
@@ -125,6 +131,7 @@ if TYPE_CHECKING:
|
|
|
125
131
|
|
|
126
132
|
REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
127
133
|
{
|
|
134
|
+
CreatedAtOrderRequiresTiebreaker.id: CreatedAtOrderRequiresTiebreaker,
|
|
128
135
|
NoStringConcatInLoop.id: NoStringConcatInLoop,
|
|
129
136
|
PreferClassRow.id: PreferClassRow,
|
|
130
137
|
PreferStrEnum.id: PreferStrEnum,
|
|
@@ -196,6 +203,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
|
196
203
|
NoLongComment.id: NoLongComment,
|
|
197
204
|
NoTypedDocSections.id: NoTypedDocSections,
|
|
198
205
|
PreferNominalIdTypes.id: PreferNominalIdTypes,
|
|
206
|
+
NoUniqueViolationMessageMatch.id: NoUniqueViolationMessageMatch,
|
|
199
207
|
}
|
|
200
208
|
)
|
|
201
209
|
|
|
@@ -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,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
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/__init__.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/_ast_index.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/_comments.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/_docstrings.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/_first_party.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/_imports.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/_logging.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/_prose_budget.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/_test_assertions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_comment_cruft.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_long_comment.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_secret_in_log.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/no_select_star.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/over_mocked_test.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/prefer_class_row.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/prefer_str_enum.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.67.11 → sarj_python_lint-0.67.13}/src/sarj_python_lint/rules/stepdown.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|