sarj-python-lint 0.27.0__tar.gz → 0.28.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.
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/PKG-INFO +1 -1
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/pyproject.toml +1 -1
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/_comments.py +35 -5
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/_registry.py +0 -6
- sarj_python_lint-0.28.0/src/sarj_python_lint/rules/_suppression_comments.py +69 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +29 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +35 -109
- sarj_python_lint-0.27.0/src/sarj_python_lint/rules/_suppression_comments.py +0 -96
- sarj_python_lint-0.27.0/src/sarj_python_lint/rules/prefer_pattern_matching.py +0 -343
- sarj_python_lint-0.27.0/src/sarj_python_lint/rules/primary_export_file_name.py +0 -149
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/.gitignore +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/README.md +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/interaction_only_test.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_implicit_attribute_access.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_patching_system_under_test.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/zero_assertion_test.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sarj-python-lint
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.28.0
|
|
4
4
|
Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
|
|
5
5
|
Project-URL: Homepage, https://github.com/sarj-ai/standards/tree/main/packages/python
|
|
6
6
|
Project-URL: Repository, https://github.com/sarj-ai/standards
|
|
@@ -397,7 +397,12 @@ def restates(comment_tokens: Sequence[str], code: Iterable[str]) -> bool:
|
|
|
397
397
|
_LAYOUT_TOKENS = frozenset({tokenize.NL, tokenize.NEWLINE, tokenize.INDENT, tokenize.DEDENT})
|
|
398
398
|
_NON_CODE_TOKENS = _LAYOUT_TOKENS | frozenset({tokenize.COMMENT, tokenize.ENCODING, tokenize.ENDMARKER})
|
|
399
399
|
|
|
400
|
-
|
|
400
|
+
# `(line, col0, body, standalone)` for every comment, in source order. This is
|
|
401
|
+
# what `_suppression_comments` needs, and it is a by-product of the pass below
|
|
402
|
+
# rather than a reason to run a second one — see `all_comments`.
|
|
403
|
+
_Ordered = list[tuple[int, int, str, bool]]
|
|
404
|
+
|
|
405
|
+
_Scan = tuple[list[tuple[int, int, str]], list[tuple[int, int, str]], set[int], int, _Ordered]
|
|
401
406
|
|
|
402
407
|
_last_scan: tuple[str, _Scan] | None = None
|
|
403
408
|
|
|
@@ -405,6 +410,7 @@ _last_scan: tuple[str, _Scan] | None = None
|
|
|
405
410
|
def _scan(source: str) -> _Scan:
|
|
406
411
|
standalone: list[tuple[int, int, str]] = []
|
|
407
412
|
trailing: list[tuple[int, int, str]] = []
|
|
413
|
+
ordered: _Ordered = []
|
|
408
414
|
nested: set[int] = set()
|
|
409
415
|
first_code_line = 1 << 30
|
|
410
416
|
prev_end_row = 0
|
|
@@ -412,8 +418,11 @@ def _scan(source: str) -> _Scan:
|
|
|
412
418
|
readline = io.StringIO(source).readline
|
|
413
419
|
for tok in tokenize.generate_tokens(readline):
|
|
414
420
|
if tok.type == tokenize.COMMENT:
|
|
415
|
-
|
|
416
|
-
|
|
421
|
+
body = tok.string.lstrip("#").strip()
|
|
422
|
+
entry = (tok.start[0], tok.start[1], body)
|
|
423
|
+
is_standalone = tok.start[0] != prev_end_row
|
|
424
|
+
(standalone if is_standalone else trailing).append(entry)
|
|
425
|
+
ordered.append((tok.start[0], tok.start[1], body, is_standalone))
|
|
417
426
|
if depth > 0:
|
|
418
427
|
nested.add(tok.start[0])
|
|
419
428
|
elif tok.type == tokenize.OP:
|
|
@@ -425,7 +434,28 @@ def _scan(source: str) -> _Scan:
|
|
|
425
434
|
prev_end_row = tok.end[0]
|
|
426
435
|
if tok.type not in _NON_CODE_TOKENS:
|
|
427
436
|
first_code_line = min(first_code_line, tok.start[0])
|
|
428
|
-
return standalone, trailing, nested, first_code_line
|
|
437
|
+
return standalone, trailing, nested, first_code_line, ordered
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def all_comments(source: str) -> tuple[_Ordered, int]:
|
|
441
|
+
"""Return every comment as `(line, col0, body, standalone)`, plus the first code line.
|
|
442
|
+
|
|
443
|
+
Exists so the suppression rules (SARJ038/054) can share this module's
|
|
444
|
+
tokenize pass instead of running a second one. Both scanners computed the
|
|
445
|
+
same three facts — comment text, whether it stands alone on its line, and
|
|
446
|
+
where the first real code token is — from identical token-class sets, so the
|
|
447
|
+
second pass was pure duplicated work: SARJ038 alone spent ~4% of total rule
|
|
448
|
+
time on it.
|
|
449
|
+
|
|
450
|
+
`col0` is 0-based, matching this module's other accessors; the suppression
|
|
451
|
+
layer adds one for its 1-based `Comment.col`.
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
The ordered comments and the first code line's row.
|
|
455
|
+
|
|
456
|
+
"""
|
|
457
|
+
_, _, _, first_code_line, ordered = _scan_memo(source)
|
|
458
|
+
return ordered, first_code_line
|
|
429
459
|
|
|
430
460
|
|
|
431
461
|
def _scan_memo(source: str) -> _Scan:
|
|
@@ -482,7 +512,7 @@ def standalone_comments(source: str) -> tuple[list[tuple[int, int, str]], int]:
|
|
|
482
512
|
The standalone comments and the first code line's row.
|
|
483
513
|
|
|
484
514
|
"""
|
|
485
|
-
standalone, _, _, first_code_line = _scan_memo(source)
|
|
515
|
+
standalone, _, _, first_code_line, _ = _scan_memo(source)
|
|
486
516
|
return standalone, first_code_line
|
|
487
517
|
|
|
488
518
|
|
|
@@ -69,7 +69,6 @@ from sarj_python_lint.rules.prefer_non_nullable_collection import (
|
|
|
69
69
|
PreferNonNullableCollection,
|
|
70
70
|
)
|
|
71
71
|
from sarj_python_lint.rules.prefer_or_pattern import PreferOrPattern
|
|
72
|
-
from sarj_python_lint.rules.prefer_pattern_matching import PreferPatternMatching
|
|
73
72
|
from sarj_python_lint.rules.prefer_real_store_in_tests import PreferRealStoreInTests
|
|
74
73
|
from sarj_python_lint.rules.prefer_self_type_annotation import PreferSelfTypeAnnotation
|
|
75
74
|
from sarj_python_lint.rules.prefer_str_enum import PreferStrEnum
|
|
@@ -84,9 +83,6 @@ from sarj_python_lint.rules.prefer_walrus_comprehension_filter import (
|
|
|
84
83
|
)
|
|
85
84
|
from sarj_python_lint.rules.prefer_walrus_regex_match import PreferWalrusRegexMatch
|
|
86
85
|
from sarj_python_lint.rules.prefer_walrus_stream_loop import PreferWalrusStreamLoop
|
|
87
|
-
from sarj_python_lint.rules.primary_export_file_name import (
|
|
88
|
-
PrimaryExportFileName,
|
|
89
|
-
)
|
|
90
86
|
from sarj_python_lint.rules.pydantic_at_boundaries import PydanticAtBoundaries
|
|
91
87
|
from sarj_python_lint.rules.redundant_docstring import RedundantDocstring
|
|
92
88
|
from sarj_python_lint.rules.require_port_for_service import RequirePortForService
|
|
@@ -173,11 +169,9 @@ REGISTRY: dict[str, type[Rule]] = {
|
|
|
173
169
|
PreferFstringOverConcat.id: PreferFstringOverConcat,
|
|
174
170
|
PreferMatchPatternDestructuring.id: PreferMatchPatternDestructuring,
|
|
175
171
|
PreferOrPattern.id: PreferOrPattern,
|
|
176
|
-
PreferPatternMatching.id: PreferPatternMatching,
|
|
177
172
|
RequirePortForService.id: RequirePortForService,
|
|
178
173
|
PreferNonNullableCollection.id: PreferNonNullableCollection,
|
|
179
174
|
PreferMatchTypeDispatch.id: PreferMatchTypeDispatch,
|
|
180
|
-
PrimaryExportFileName.id: PrimaryExportFileName,
|
|
181
175
|
PreferWalrusRegexMatch.id: PreferWalrusRegexMatch,
|
|
182
176
|
PreferWalrusComprehensionFilter.id: PreferWalrusComprehensionFilter,
|
|
183
177
|
PreferWalrusStreamLoop.id: PreferWalrusStreamLoop,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Shared comment tokenizer for the suppression-directive rules (SARJ038, SARJ054).
|
|
2
|
+
|
|
3
|
+
Comments do not survive `ast.parse`, so any rule that judges a suppression
|
|
4
|
+
*comment* has to lex the file. Both suppression rules need the same three facts
|
|
5
|
+
about every comment — its text, whether it stands alone on its line, and whether
|
|
6
|
+
it precedes the module's first statement — so they share one scanner rather than
|
|
7
|
+
drifting apart on what "file-level" means.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from sarj_python_lint.rules._comments import all_comments
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class Comment:
|
|
19
|
+
"""One comment token, with the context that decides whether it is file-level."""
|
|
20
|
+
|
|
21
|
+
line: int
|
|
22
|
+
col: int
|
|
23
|
+
body: str
|
|
24
|
+
standalone: bool
|
|
25
|
+
before_first_statement: bool = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def scan_comments(source: str) -> list[Comment]:
|
|
29
|
+
"""Describe every comment in `source`, in source order.
|
|
30
|
+
|
|
31
|
+
A comment is standalone when no token ended on its line before it, and
|
|
32
|
+
precedes the first statement when it sits above the first non-comment,
|
|
33
|
+
non-layout token.
|
|
34
|
+
|
|
35
|
+
Anything `tokenize` cannot lex propagates as `tokenize.TokenError`,
|
|
36
|
+
`IndentationError` or `SyntaxError`; callers treat that as "no diagnostics".
|
|
37
|
+
|
|
38
|
+
Built from `_comments.all_comments`, which is the same tokenize pass the
|
|
39
|
+
comment-hygiene rules already run. This module used to lex the file a second
|
|
40
|
+
time to compute exactly the same three facts from identical token-class
|
|
41
|
+
sets, which cost SARJ038 ~4% of total rule time for no additional
|
|
42
|
+
information. The memo now lives in one place rather than two.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Every comment, in source order. `Comment` is frozen and the list is
|
|
46
|
+
read-only to callers.
|
|
47
|
+
|
|
48
|
+
"""
|
|
49
|
+
ordered, first_statement_line = all_comments(source)
|
|
50
|
+
return [
|
|
51
|
+
Comment(
|
|
52
|
+
line=line,
|
|
53
|
+
col=col + 1,
|
|
54
|
+
body=body,
|
|
55
|
+
standalone=standalone,
|
|
56
|
+
before_first_statement=line < first_statement_line,
|
|
57
|
+
)
|
|
58
|
+
for line, col, body, standalone in ordered
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def comment_body(raw: str) -> str:
|
|
63
|
+
"""Strip a comment token down to its directive text.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
The comment text without its leading `#` markers or surrounding space.
|
|
67
|
+
|
|
68
|
+
"""
|
|
69
|
+
return raw.lstrip("#").strip()
|
|
@@ -66,6 +66,11 @@ if TYPE_CHECKING:
|
|
|
66
66
|
|
|
67
67
|
_GEN_RANDOM_UUID_RE = re.compile(r"\bgen_random_uuid\s*\(", re.IGNORECASE)
|
|
68
68
|
|
|
69
|
+
# The whole-source gate in `check`. Deliberately weaker than the pattern above —
|
|
70
|
+
# the identifier alone, no paren — so it cannot exclude a file whose masked SQL
|
|
71
|
+
# would have matched. See the comment at its use site.
|
|
72
|
+
_NAMES_GEN_RANDOM_UUID_RE = re.compile(r"gen_random_uuid", re.IGNORECASE)
|
|
73
|
+
|
|
69
74
|
# A string only counts as SQL when it carries a structural keyword. Without this
|
|
70
75
|
# the rule fires on prose that names the function — including this module's own
|
|
71
76
|
# docstring.
|
|
@@ -111,6 +116,30 @@ class NoGenRandomUuidInSql(Rule):
|
|
|
111
116
|
The diagnostics, sorted by (line, col).
|
|
112
117
|
|
|
113
118
|
"""
|
|
119
|
+
# Naming the function is a NECESSARY condition for any finding: the
|
|
120
|
+
# match runs against `strip_sql_noise(literal)`, and that only ever
|
|
121
|
+
# removes characters, so text absent from the file cannot appear in the
|
|
122
|
+
# masked literal. Checking the raw source first skips the parse and the
|
|
123
|
+
# per-literal SQL masking for the ~100% of files that never mention it.
|
|
124
|
+
#
|
|
125
|
+
# This rule is not store-gated and so masked every string literal in
|
|
126
|
+
# every file, which made it the worst cost-per-finding in the registry
|
|
127
|
+
# (2 findings across 21 corpora, at ~125 ms each). The gate removes that
|
|
128
|
+
# for no loss of coverage — verified by measuring the corpus before and
|
|
129
|
+
# after, not by inspection.
|
|
130
|
+
#
|
|
131
|
+
# The gate deliberately tests only the identifier, NOT the full
|
|
132
|
+
# `_GEN_RANDOM_UUID_RE`, which also requires the open paren. Masking can
|
|
133
|
+
# delete characters *between* the name and its paren — `gen_random_uuid
|
|
134
|
+
# /* c */ ()` masks to `gen_random_uuid ()` and matches — so gating on
|
|
135
|
+
# the stricter pattern would drop a finding the rule would otherwise
|
|
136
|
+
# report. A gate must be strictly weaker than the predicate it guards.
|
|
137
|
+
#
|
|
138
|
+
# It is case-insensitive for the same reason `_GEN_RANDOM_UUID_RE` is: a
|
|
139
|
+
# `DEFAULT GEN_RANDOM_UUID()` in upper-case SQL is a real finding, and a
|
|
140
|
+
# plain `"gen_random_uuid" in source` test would silently drop it.
|
|
141
|
+
if not _NAMES_GEN_RANDOM_UUID_RE.search(source):
|
|
142
|
+
return []
|
|
114
143
|
if is_generated_source(source):
|
|
115
144
|
return []
|
|
116
145
|
tree = parse_or_none(path, source)
|
|
@@ -1,12 +1,38 @@
|
|
|
1
|
-
"""SARJ080: prefer match/case over
|
|
1
|
+
"""SARJ080: prefer match/case over sequential sentinel/type guards.
|
|
2
2
|
|
|
3
3
|
Parsers and field deserializers often contain hideous type-dispatch idioms:
|
|
4
|
-
sequential `if x is None: return x` / `if isinstance(x, Unset): return x` guards
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
sequential `if x is None: return x` / `if isinstance(x, Unset): return x` guards
|
|
5
|
+
walking a value through one shape at a time, where a single `match` states the
|
|
6
|
+
whole dispatch at once.
|
|
7
|
+
|
|
8
|
+
DELIBERATELY NOT FLAGGED: `raise` inside a `try` block
|
|
9
|
+
------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
This rule used to carry a second detector for the other half of that idiom —
|
|
12
|
+
raising inside a `try` purely to jump into its own `except` handler, i.e. using
|
|
13
|
+
`raise` as a goto. That detector was removed because ruff already reports it and
|
|
14
|
+
our shipped config already enables it.
|
|
15
|
+
|
|
16
|
+
`ruff.strict.toml` selects `ALL` and does not ignore `TRY`, so `TRY301`
|
|
17
|
+
(`raise-within-try`, "Abstract `raise` to an inner function") is live in every
|
|
18
|
+
consumer. Measured across 21 corpora, the removed arm produced 1,756 findings of
|
|
19
|
+
which 1,649 (93.9%) sat on a line ruff already flagged, and construction
|
|
20
|
+
confirms the columns match exactly, not merely the line:
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
if not isinstance(x, str):
|
|
24
|
+
raise TypeError # ruff TRY301 at 13:13, old SARJ080 at 13:13
|
|
25
|
+
|
|
26
|
+
The 107 positions TRY301 structurally misses are `raise`s inside an `except`
|
|
27
|
+
body caught by an *outer* `try`. That is a real gap, but not one worth 1,649
|
|
28
|
+
double reports — and 5 of those positions already carry an explicit
|
|
29
|
+
`# noqa: TRY301`, a decision the team made that a second code would quietly
|
|
30
|
+
re-open.
|
|
31
|
+
|
|
32
|
+
The surviving sequential-guard detector has NO ruff counterpart: the same sweep
|
|
33
|
+
found 0 of its 476 positions shared with TRY301, and `RET505` is the closest
|
|
34
|
+
thing ruff has, firing only on `elif`-after-`return` rather than on separate
|
|
35
|
+
`if ...: return` statements.
|
|
10
36
|
|
|
11
37
|
Preferred Python 3.10+ match/case patterns:
|
|
12
38
|
- For `None`: `case None:`
|
|
@@ -42,59 +68,9 @@ if TYPE_CHECKING:
|
|
|
42
68
|
from pathlib import Path
|
|
43
69
|
|
|
44
70
|
|
|
45
|
-
_GENERIC_EXCEPTIONS = frozenset({"Exception", "BaseException"})
|
|
46
71
|
_MIN_SENTINEL_COUNT = 2
|
|
47
72
|
|
|
48
73
|
|
|
49
|
-
def _get_caught_exception_names(handlers: list[ast.ExceptHandler]) -> set[str] | None:
|
|
50
|
-
"""Extract caught exception class names.
|
|
51
|
-
|
|
52
|
-
Returns:
|
|
53
|
-
A set of exception class names, or None if a catch-all handler (bare `except:`) is present.
|
|
54
|
-
|
|
55
|
-
"""
|
|
56
|
-
caught: set[str] = set()
|
|
57
|
-
for h in handlers:
|
|
58
|
-
if h.type is None:
|
|
59
|
-
return None
|
|
60
|
-
if isinstance(h.type, ast.Name):
|
|
61
|
-
caught.add(h.type.id)
|
|
62
|
-
elif isinstance(h.type, ast.Tuple):
|
|
63
|
-
for elt in h.type.elts:
|
|
64
|
-
if isinstance(elt, ast.Name):
|
|
65
|
-
caught.add(elt.id)
|
|
66
|
-
elif isinstance(elt, ast.Attribute):
|
|
67
|
-
caught.add(elt.attr)
|
|
68
|
-
caught.add(ast.unparse(elt))
|
|
69
|
-
elif isinstance(h.type, ast.Attribute):
|
|
70
|
-
caught.add(h.type.attr)
|
|
71
|
-
caught.add(ast.unparse(h.type))
|
|
72
|
-
return caught
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def _raised_exception_name(raise_node: ast.Raise) -> str | None:
|
|
76
|
-
"""Extract exception class name from `raise Exc()` or `raise Exc`.
|
|
77
|
-
|
|
78
|
-
Returns:
|
|
79
|
-
The exception class name, or None if no exception is specified.
|
|
80
|
-
|
|
81
|
-
"""
|
|
82
|
-
exc = raise_node.exc
|
|
83
|
-
if exc is None:
|
|
84
|
-
return None
|
|
85
|
-
if isinstance(exc, ast.Call):
|
|
86
|
-
func = exc.func
|
|
87
|
-
if isinstance(func, ast.Name):
|
|
88
|
-
return func.id
|
|
89
|
-
if isinstance(func, ast.Attribute):
|
|
90
|
-
return func.attr
|
|
91
|
-
elif isinstance(exc, ast.Name):
|
|
92
|
-
return exc.id
|
|
93
|
-
elif isinstance(exc, ast.Attribute):
|
|
94
|
-
return exc.attr
|
|
95
|
-
return None
|
|
96
|
-
|
|
97
|
-
|
|
98
74
|
def _guard_target_var_name(stmt: ast.stmt) -> str | None:
|
|
99
75
|
"""Extract target variable name if statement is `if x is None: return ...` or `if isinstance(x, ...): return ...`.
|
|
100
76
|
|
|
@@ -191,75 +167,25 @@ class _TypeDispatchVisitor(ast.NodeVisitor):
|
|
|
191
167
|
self.path: Path = path
|
|
192
168
|
self.code: str = code
|
|
193
169
|
self.diags: list[Diagnostic] = []
|
|
194
|
-
self.try_stack: list[set[str] | None] = []
|
|
195
|
-
|
|
196
|
-
def visit_Try(self, node: ast.Try | ast.TryStar) -> None:
|
|
197
|
-
caught = _get_caught_exception_names(node.handlers)
|
|
198
|
-
self.try_stack.append(caught)
|
|
199
|
-
for stmt in node.body:
|
|
200
|
-
self.visit(stmt)
|
|
201
|
-
self.try_stack.pop()
|
|
202
|
-
for handler in node.handlers:
|
|
203
|
-
self.visit(handler)
|
|
204
|
-
for stmt in node.orelse:
|
|
205
|
-
self.visit(stmt)
|
|
206
|
-
for stmt in node.finalbody:
|
|
207
|
-
self.visit(stmt)
|
|
208
|
-
|
|
209
|
-
@override
|
|
210
|
-
def visit_TryStar(self, node: ast.TryStar) -> None:
|
|
211
|
-
self.visit_Try(node)
|
|
212
|
-
|
|
213
|
-
def visit_Raise(self, node: ast.Raise) -> None:
|
|
214
|
-
if self.try_stack:
|
|
215
|
-
exc_name = _raised_exception_name(node)
|
|
216
|
-
if exc_name is not None:
|
|
217
|
-
# Search outward through active try scopes
|
|
218
|
-
for caught in reversed(self.try_stack):
|
|
219
|
-
is_caught = caught is None or exc_name in caught or bool(caught & _GENERIC_EXCEPTIONS)
|
|
220
|
-
if is_caught:
|
|
221
|
-
self.diags.append(
|
|
222
|
-
Diagnostic(
|
|
223
|
-
path=self.path,
|
|
224
|
-
line=node.lineno,
|
|
225
|
-
col=node.col_offset + 1,
|
|
226
|
-
code=self.code,
|
|
227
|
-
message=(
|
|
228
|
-
f"Control-flow raise in try block — 'raise {exc_name}()' "
|
|
229
|
-
f"jumps directly to local except handler. Refactor to 'match/case' "
|
|
230
|
-
f"(e.g., 'case str():') to handle types directly."
|
|
231
|
-
),
|
|
232
|
-
)
|
|
233
|
-
)
|
|
234
|
-
break
|
|
235
|
-
self.generic_visit(node)
|
|
236
170
|
|
|
237
171
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
238
172
|
self.diags.extend(_check_sequential_type_guards(node, self.path, self.code))
|
|
239
|
-
saved_stack = self.try_stack
|
|
240
|
-
self.try_stack = []
|
|
241
173
|
self.generic_visit(node)
|
|
242
|
-
self.try_stack = saved_stack
|
|
243
174
|
|
|
244
175
|
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
245
176
|
self.diags.extend(_check_sequential_type_guards(node, self.path, self.code))
|
|
246
|
-
saved_stack = self.try_stack
|
|
247
|
-
self.try_stack = []
|
|
248
177
|
self.generic_visit(node)
|
|
249
|
-
self.try_stack = saved_stack
|
|
250
178
|
|
|
251
179
|
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
252
180
|
self.generic_visit(node)
|
|
253
181
|
|
|
254
182
|
|
|
255
183
|
class PreferMatchTypeDispatch(Rule):
|
|
256
|
-
"""Prefer match/case over
|
|
184
|
+
"""Prefer match/case over sequential sentinel/type guards."""
|
|
257
185
|
|
|
258
186
|
id: str = "prefer-match-type-dispatch"
|
|
259
187
|
code: str = "SARJ080"
|
|
260
|
-
description: str =
|
|
261
|
-
"Control-flow raise in try block or sequential type guards — prefer Python 3.10+ match/case pattern matching."
|
|
262
|
-
)
|
|
188
|
+
description: str = "Sequential sentinel/type guards — prefer Python 3.10+ match/case pattern matching."
|
|
263
189
|
|
|
264
190
|
@override
|
|
265
191
|
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
"""Shared comment tokenizer for the suppression-directive rules (SARJ038, SARJ054).
|
|
2
|
-
|
|
3
|
-
Comments do not survive `ast.parse`, so any rule that judges a suppression
|
|
4
|
-
*comment* has to lex the file. Both suppression rules need the same three facts
|
|
5
|
-
about every comment — its text, whether it stands alone on its line, and whether
|
|
6
|
-
it precedes the module's first statement — so they share one scanner rather than
|
|
7
|
-
drifting apart on what "file-level" means.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
from dataclasses import dataclass, replace
|
|
13
|
-
import io
|
|
14
|
-
import tokenize
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
# Sentinel row for "this file has no statement at all" (empty / comments-only):
|
|
18
|
-
# every comment then counts as preceding the first statement.
|
|
19
|
-
_NO_STATEMENT_LINE = 1 << 30
|
|
20
|
-
|
|
21
|
-
_LAYOUT_TOKENS = frozenset({tokenize.NL, tokenize.NEWLINE, tokenize.INDENT, tokenize.DEDENT})
|
|
22
|
-
_NON_STATEMENT_TOKENS = _LAYOUT_TOKENS | frozenset({tokenize.COMMENT, tokenize.ENCODING, tokenize.ENDMARKER})
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@dataclass(frozen=True, slots=True)
|
|
26
|
-
class Comment:
|
|
27
|
-
"""One comment token, with the context that decides whether it is file-level."""
|
|
28
|
-
|
|
29
|
-
line: int
|
|
30
|
-
col: int
|
|
31
|
-
body: str
|
|
32
|
-
standalone: bool
|
|
33
|
-
before_first_statement: bool = False
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
_last_scan: tuple[str, list[Comment]] | None = None
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def scan_comments(source: str) -> list[Comment]:
|
|
40
|
-
"""Tokenize `source` and describe every comment in it.
|
|
41
|
-
|
|
42
|
-
A comment is standalone when no token ended on its line before it, and
|
|
43
|
-
precedes the first statement when it sits above the first non-comment,
|
|
44
|
-
non-layout token.
|
|
45
|
-
|
|
46
|
-
Anything `tokenize` cannot lex propagates as `tokenize.TokenError`,
|
|
47
|
-
`IndentationError` or `SyntaxError`; callers treat that as "no diagnostics".
|
|
48
|
-
|
|
49
|
-
Memoized in a single slot, like `_comments._scan_memo` and
|
|
50
|
-
`rule_base.parse_or_none`: both suppression rules ask the same question about
|
|
51
|
-
the same file, and the CLI runs rules per file, so one slot removes the
|
|
52
|
-
second tokenize pass. `Comment` is frozen and the returned list is read-only
|
|
53
|
-
to callers.
|
|
54
|
-
|
|
55
|
-
Returns:
|
|
56
|
-
Every comment, in source order.
|
|
57
|
-
|
|
58
|
-
"""
|
|
59
|
-
global _last_scan # ruff: ignore[global-statement] — single-slot memo; the CLI runs rules per file sequentially
|
|
60
|
-
if _last_scan is not None and _last_scan[0] is source:
|
|
61
|
-
return _last_scan[1]
|
|
62
|
-
result = _scan(source)
|
|
63
|
-
_last_scan = (source, result)
|
|
64
|
-
return result
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def _scan(source: str) -> list[Comment]:
|
|
68
|
-
comments: list[Comment] = []
|
|
69
|
-
first_statement_line = _NO_STATEMENT_LINE
|
|
70
|
-
prev_end_row = 0
|
|
71
|
-
readline = io.StringIO(source).readline
|
|
72
|
-
for tok in tokenize.generate_tokens(readline):
|
|
73
|
-
if tok.type == tokenize.COMMENT:
|
|
74
|
-
comments.append(
|
|
75
|
-
Comment(
|
|
76
|
-
line=tok.start[0],
|
|
77
|
-
col=tok.start[1] + 1,
|
|
78
|
-
body=comment_body(tok.string),
|
|
79
|
-
standalone=tok.start[0] != prev_end_row,
|
|
80
|
-
)
|
|
81
|
-
)
|
|
82
|
-
if tok.type not in _LAYOUT_TOKENS:
|
|
83
|
-
prev_end_row = tok.end[0]
|
|
84
|
-
if tok.type not in _NON_STATEMENT_TOKENS:
|
|
85
|
-
first_statement_line = min(first_statement_line, tok.start[0])
|
|
86
|
-
return [replace(c, before_first_statement=c.line < first_statement_line) for c in comments]
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def comment_body(raw: str) -> str:
|
|
90
|
-
"""Strip a comment token down to its directive text.
|
|
91
|
-
|
|
92
|
-
Returns:
|
|
93
|
-
The comment text without its leading `#` markers or surrounding space.
|
|
94
|
-
|
|
95
|
-
"""
|
|
96
|
-
return raw.lstrip("#").strip()
|