sarj-python-lint 0.27.0__tar.gz → 0.29.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.29.0}/PKG-INFO +1 -1
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/pyproject.toml +1 -1
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/_comments.py +35 -5
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/_logging.py +4 -1
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/_registry.py +0 -6
- sarj_python_lint-0.29.0/src/sarj_python_lint/rules/_suppression_comments.py +59 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +40 -3
- 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.29.0}/.gitignore +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/README.md +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/interaction_only_test.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.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.29.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_implicit_attribute_access.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_patching_system_under_test.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.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.29.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
- {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.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.29.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
|
|
|
@@ -12,7 +12,10 @@ import ast
|
|
|
12
12
|
|
|
13
13
|
_LOGGER_NAMES = frozenset({"logger", "log", "logging", "loguru", "_logger", "_log"})
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
# Public: `no_fstring_in_log._chain_has_getlogger` must test the SAME set with
|
|
16
|
+
# the SAME casing, or a factory can be a logger to one and not the other.
|
|
17
|
+
LOGGER_FACTORIES = frozenset({"getlogger", "get_logger"})
|
|
18
|
+
_LOGGER_FACTORIES = LOGGER_FACTORIES
|
|
16
19
|
|
|
17
20
|
|
|
18
21
|
def is_logger_expr(expr: ast.expr) -> bool:
|
|
@@ -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,59 @@
|
|
|
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
|
+
]
|
{sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_fstring_in_log.py
RENAMED
|
@@ -76,7 +76,7 @@ from typing import TYPE_CHECKING, override
|
|
|
76
76
|
|
|
77
77
|
from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
|
|
78
78
|
from sarj_python_lint.rules._ast_index import nodes
|
|
79
|
-
from sarj_python_lint.rules._logging import is_logger_expr
|
|
79
|
+
from sarj_python_lint.rules._logging import LOGGER_FACTORIES, is_logger_expr
|
|
80
80
|
|
|
81
81
|
|
|
82
82
|
if TYPE_CHECKING:
|
|
@@ -102,6 +102,11 @@ _LOG_METHODS = frozenset(
|
|
|
102
102
|
# Keyword arguments defined by stdlib `logging` (and never structured fields).
|
|
103
103
|
# Their presence marks the call as a stdlib logger, for which the loguru-style
|
|
104
104
|
# structured-keyword rewrite is wrong.
|
|
105
|
+
# The stdlib factory spelling. `logging.getLogger` is camelCase; structlog uses
|
|
106
|
+
# snake_case `get_logger`, and that difference is the only static signal telling
|
|
107
|
+
# a dotted stdlib factory from a dotted structlog one.
|
|
108
|
+
_STDLIB_FACTORY = "getLogger"
|
|
109
|
+
|
|
105
110
|
_STDLIB_ONLY_KWARGS = frozenset({"exc_info", "stack_info", "extra"})
|
|
106
111
|
|
|
107
112
|
|
|
@@ -272,13 +277,45 @@ def _is_stdlib_logging_call(node: ast.Call, stdlib: _StdlibLoggers) -> bool:
|
|
|
272
277
|
|
|
273
278
|
|
|
274
279
|
def _chain_has_getlogger(expr: ast.expr) -> bool:
|
|
280
|
+
"""Report whether a stdlib `logging` factory appears anywhere in the receiver chain.
|
|
281
|
+
|
|
282
|
+
Matched against the SAME set and casing as `_logging.is_logger_expr`, and that
|
|
283
|
+
parity is load-bearing. When `is_logger_expr` learned to recognise a bare
|
|
284
|
+
`get_logger()` callee, this guard still tested the exact string `"getLogger"`,
|
|
285
|
+
so a snake_case factory returning a *stdlib* logger became a logger to the
|
|
286
|
+
rule but not a stdlib logger to the guard — and the rule then advised
|
|
287
|
+
`logger.info("msg", key=value)` on an API that rejects it.
|
|
288
|
+
|
|
289
|
+
That advice fails in the worst possible way: stdlib `Logger._log` raises
|
|
290
|
+
`TypeError: got an unexpected keyword argument`, but only when the call is
|
|
291
|
+
actually emitted, so the code is green at the default WARNING level and
|
|
292
|
+
breaks the moment log level is raised to INFO. The shim that triggers it —
|
|
293
|
+
`def get_logger(name): return logging.getLogger(name)` — ships in
|
|
294
|
+
huggingface_hub, transformers, fastmcp, mcp and speechmatics, all present in
|
|
295
|
+
consumer virtualenvs.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
True when the chain names a stdlib logging factory.
|
|
299
|
+
|
|
300
|
+
"""
|
|
275
301
|
node = expr
|
|
276
302
|
while True:
|
|
277
303
|
if isinstance(node, ast.Call):
|
|
278
304
|
called = node.func
|
|
279
|
-
|
|
305
|
+
# DOTTED callee: only the stdlib spelling counts. `structlog.get_logger()`
|
|
306
|
+
# must NOT be suppressed — structlog's keyword API is exactly what this
|
|
307
|
+
# rule's advice targets, and it is the rule's main true positive.
|
|
308
|
+
if isinstance(called, ast.Attribute) and called.attr == _STDLIB_FACTORY:
|
|
280
309
|
return True
|
|
281
|
-
|
|
310
|
+
# BARE callee: `get_logger()` and `getLogger()` are both ambiguous — the
|
|
311
|
+
# name alone cannot tell `from structlog import get_logger` from a shim
|
|
312
|
+
# `def get_logger(n): return logging.getLogger(n)`, and such shims ship in
|
|
313
|
+
# huggingface_hub, transformers, fastmcp, mcp and speechmatics. Suppress
|
|
314
|
+
# both, because the failure is asymmetric: a false NEGATIVE costs one
|
|
315
|
+
# style nit, while a false POSITIVE advises `logger.info("m", k=v)` on a
|
|
316
|
+
# stdlib logger, which raises `TypeError` — and only once log level is
|
|
317
|
+
# raised to INFO, so it is green in tests and breaks in production.
|
|
318
|
+
if isinstance(called, ast.Name) and called.id.lower() in LOGGER_FACTORIES:
|
|
282
319
|
return True
|
|
283
320
|
node = called
|
|
284
321
|
elif isinstance(node, ast.Attribute):
|
|
@@ -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()
|
|
@@ -1,343 +0,0 @@
|
|
|
1
|
-
"""SARJ079: comprehensive Python pattern matching and match-assignment rule.
|
|
2
|
-
|
|
3
|
-
Combines all pattern-matching and match-expression quality checks into a unified rule:
|
|
4
|
-
|
|
5
|
-
1. Consecutive `case` arms with identical bodies -> merge into `case A() | B():`
|
|
6
|
-
2. `case Cls():` arms reaching back for subject fields -> use pattern destructuring `case Cls(a=a, b=b):`
|
|
7
|
-
3. Closed-set `match` dispatch with silent fallthrough `case _:` -> require `assert_never(...)`
|
|
8
|
-
4. Regex match pre-assignment before `if` -> use assignment expression `if match := re.search(...):`
|
|
9
|
-
|
|
10
|
-
Corpus evidence. Sweeping across 7 repositories (bulbul, noura-be, fastapi, pydantic, httpx, requests, rich)
|
|
11
|
-
validates pattern matching best practices across 2,032 Python source files with 0 false positives.
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
from __future__ import annotations
|
|
15
|
-
|
|
16
|
-
import ast
|
|
17
|
-
from typing import TYPE_CHECKING, override
|
|
18
|
-
|
|
19
|
-
from sarj_python_lint.rule_base import Diagnostic, Rule, is_suppressed, parse_or_none
|
|
20
|
-
from sarj_python_lint.rules._ast_index import nodes, walk
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if TYPE_CHECKING:
|
|
24
|
-
from pathlib import Path
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
# --------------------------------------------------------------------------- #
|
|
28
|
-
# Sub-check 1: Or-Patterns #
|
|
29
|
-
# --------------------------------------------------------------------------- #
|
|
30
|
-
|
|
31
|
-
_MIN_RUN = 2
|
|
32
|
-
_MIN_ATTR_READS = 2
|
|
33
|
-
_MIN_REAL_CASES = 2
|
|
34
|
-
_EMPTY_BODY_NODES = (ast.Pass,)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def _is_empty_body(body: list[ast.stmt]) -> bool:
|
|
38
|
-
if len(body) != 1:
|
|
39
|
-
return False
|
|
40
|
-
stmt = body[0]
|
|
41
|
-
return isinstance(stmt, _EMPTY_BODY_NODES) or (
|
|
42
|
-
isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant) and stmt.value.value is Ellipsis
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def _is_irrefutable(pattern: ast.pattern) -> bool:
|
|
47
|
-
return isinstance(pattern, ast.MatchAs) and pattern.pattern is None
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def _is_mergeable_arm(case: ast.match_case) -> bool:
|
|
51
|
-
if case.guard is not None:
|
|
52
|
-
return False
|
|
53
|
-
if _is_irrefutable(case.pattern):
|
|
54
|
-
return False
|
|
55
|
-
return not _is_empty_body(case.body)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def _bound_names(pattern: ast.pattern) -> set[str]:
|
|
59
|
-
names: set[str] = set()
|
|
60
|
-
|
|
61
|
-
class BoundNameVisitor(ast.NodeVisitor):
|
|
62
|
-
def visit_MatchAs(self, node: ast.MatchAs) -> None:
|
|
63
|
-
if node.name:
|
|
64
|
-
names.add(node.name)
|
|
65
|
-
self.generic_visit(node)
|
|
66
|
-
|
|
67
|
-
def visit_MatchStar(self, node: ast.MatchStar) -> None:
|
|
68
|
-
if node.name:
|
|
69
|
-
names.add(node.name)
|
|
70
|
-
self.generic_visit(node)
|
|
71
|
-
|
|
72
|
-
def visit_MatchMapping(self, node: ast.MatchMapping) -> None:
|
|
73
|
-
if node.rest:
|
|
74
|
-
names.add(node.rest)
|
|
75
|
-
self.generic_visit(node)
|
|
76
|
-
|
|
77
|
-
BoundNameVisitor().visit(pattern)
|
|
78
|
-
return names
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def _nodes_equal(n1: ast.AST, n2: ast.AST) -> bool:
|
|
82
|
-
return ast.dump(n1) == ast.dump(n2)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def _arms_merge(a: ast.match_case, b: ast.match_case) -> bool:
|
|
86
|
-
if _bound_names(a.pattern) != _bound_names(b.pattern):
|
|
87
|
-
return False
|
|
88
|
-
if len(a.body) != len(b.body):
|
|
89
|
-
return False
|
|
90
|
-
return all(map(_nodes_equal, a.body, b.body, strict=True))
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def _mergeable_runs(node: ast.Match) -> list[list[ast.match_case]]:
|
|
94
|
-
runs: list[list[ast.match_case]] = []
|
|
95
|
-
current: list[ast.match_case] = []
|
|
96
|
-
for case in node.cases:
|
|
97
|
-
if current and _is_mergeable_arm(case) and _arms_merge(current[-1], case):
|
|
98
|
-
current.append(case)
|
|
99
|
-
continue
|
|
100
|
-
if len(current) >= _MIN_RUN:
|
|
101
|
-
runs.append(current)
|
|
102
|
-
current = [case] if _is_mergeable_arm(case) else []
|
|
103
|
-
if len(current) >= _MIN_RUN:
|
|
104
|
-
runs.append(current)
|
|
105
|
-
return runs
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def _render_pattern(pattern: ast.pattern) -> str:
|
|
109
|
-
if isinstance(pattern, ast.MatchClass):
|
|
110
|
-
cls_name = ast.unparse(pattern.cls) if hasattr(ast, "unparse") else "Class"
|
|
111
|
-
return f"{cls_name}()"
|
|
112
|
-
if isinstance(pattern, ast.MatchValue):
|
|
113
|
-
return ast.unparse(pattern.value) if hasattr(ast, "unparse") else "Value"
|
|
114
|
-
return "pattern"
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
# --------------------------------------------------------------------------- #
|
|
118
|
-
# Sub-check 2: Pattern Destructuring #
|
|
119
|
-
# --------------------------------------------------------------------------- #
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def _check_destructuring(node: ast.Match, path: Path, source_lines: list[str], code: str) -> list[Diagnostic]:
|
|
123
|
-
diags: list[Diagnostic] = []
|
|
124
|
-
if not isinstance(node.subject, ast.Name):
|
|
125
|
-
return []
|
|
126
|
-
subject_name = node.subject.id
|
|
127
|
-
|
|
128
|
-
for case in node.cases:
|
|
129
|
-
if not isinstance(case.pattern, ast.MatchClass):
|
|
130
|
-
continue
|
|
131
|
-
if case.pattern.patterns or case.pattern.kwd_attrs:
|
|
132
|
-
continue
|
|
133
|
-
|
|
134
|
-
attr_reads: set[str] = set()
|
|
135
|
-
for b_node in case.body:
|
|
136
|
-
for subnode in walk(b_node):
|
|
137
|
-
if (
|
|
138
|
-
isinstance(subnode, ast.Attribute)
|
|
139
|
-
and isinstance(subnode.value, ast.Name)
|
|
140
|
-
and subnode.value.id == subject_name
|
|
141
|
-
):
|
|
142
|
-
attr_reads.add(subnode.attr)
|
|
143
|
-
|
|
144
|
-
if len(attr_reads) >= _MIN_ATTR_READS:
|
|
145
|
-
line = case.pattern.lineno
|
|
146
|
-
col = case.pattern.col_offset + 1
|
|
147
|
-
if not is_suppressed(source_lines, line, code):
|
|
148
|
-
fields_str = ", ".join(sorted(attr_reads))
|
|
149
|
-
cls_name = _render_pattern(case.pattern)
|
|
150
|
-
diags.append(
|
|
151
|
-
Diagnostic(
|
|
152
|
-
path=path,
|
|
153
|
-
line=line,
|
|
154
|
-
col=col,
|
|
155
|
-
code=code,
|
|
156
|
-
message=(
|
|
157
|
-
f"`case {cls_name}:` reads fields ({fields_str}) from subject `{subject_name}` — "
|
|
158
|
-
f"use pattern destructuring `case {cls_name[:-2]}({fields_str}):` instead."
|
|
159
|
-
),
|
|
160
|
-
)
|
|
161
|
-
)
|
|
162
|
-
return diags
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
# --------------------------------------------------------------------------- #
|
|
166
|
-
# Sub-check 3: Closed-set Assert Never #
|
|
167
|
-
# --------------------------------------------------------------------------- #
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
def _check_assert_never(node: ast.Match, path: Path, source_lines: list[str], code: str) -> list[Diagnostic]:
|
|
171
|
-
diags: list[Diagnostic] = []
|
|
172
|
-
if not node.cases:
|
|
173
|
-
return []
|
|
174
|
-
|
|
175
|
-
last_case = node.cases[-1]
|
|
176
|
-
if not _is_irrefutable(last_case.pattern) or last_case.guard is not None:
|
|
177
|
-
return []
|
|
178
|
-
|
|
179
|
-
if _is_empty_body(last_case.body):
|
|
180
|
-
real_cases = node.cases[:-1]
|
|
181
|
-
if len(real_cases) >= _MIN_REAL_CASES and all(
|
|
182
|
-
isinstance(c.pattern, ast.MatchValue | ast.MatchClass) for c in real_cases
|
|
183
|
-
):
|
|
184
|
-
line = last_case.pattern.lineno
|
|
185
|
-
col = last_case.pattern.col_offset + 1
|
|
186
|
-
if not is_suppressed(source_lines, line, code):
|
|
187
|
-
diags.append(
|
|
188
|
-
Diagnostic(
|
|
189
|
-
path=path,
|
|
190
|
-
line=line,
|
|
191
|
-
col=col,
|
|
192
|
-
code=code,
|
|
193
|
-
message=(
|
|
194
|
-
"Closed-set `match` has a silent `case _:` fallthrough — "
|
|
195
|
-
"use `assert_never(...)` to ensure exhaustiveness."
|
|
196
|
-
),
|
|
197
|
-
)
|
|
198
|
-
)
|
|
199
|
-
return diags
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
# --------------------------------------------------------------------------- #
|
|
203
|
-
# Sub-check 4: Regex Walrus Match #
|
|
204
|
-
# --------------------------------------------------------------------------- #
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
def _is_regex_call(node: ast.AST) -> bool:
|
|
208
|
-
if not isinstance(node, ast.Call):
|
|
209
|
-
return False
|
|
210
|
-
func = node.func
|
|
211
|
-
if isinstance(func, ast.Attribute):
|
|
212
|
-
if func.attr not in {"search", "match", "fullmatch", "finditer"}:
|
|
213
|
-
return False
|
|
214
|
-
if isinstance(func.value, ast.Name) and func.value.id in {"re", "regex", "pattern", "compiled_pattern"}:
|
|
215
|
-
return True
|
|
216
|
-
if isinstance(func.value, ast.Attribute) and func.value.attr in {"pattern", "regex", "_pattern"}:
|
|
217
|
-
return True
|
|
218
|
-
return False
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
def _is_simple_truthy_test(test_node: ast.AST, var_name: str) -> bool:
|
|
222
|
-
if isinstance(test_node, ast.Name) and test_node.id == var_name:
|
|
223
|
-
return True
|
|
224
|
-
if (
|
|
225
|
-
isinstance(test_node, ast.Compare)
|
|
226
|
-
and isinstance(test_node.left, ast.Name)
|
|
227
|
-
and test_node.left.id == var_name
|
|
228
|
-
and len(test_node.ops) == 1
|
|
229
|
-
and isinstance(test_node.ops[0], ast.IsNot)
|
|
230
|
-
):
|
|
231
|
-
right = test_node.comparators[0]
|
|
232
|
-
if isinstance(right, ast.Constant) and right.value is None:
|
|
233
|
-
return True
|
|
234
|
-
return False
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
def _is_name_used_after(stmts: list[ast.stmt], start_idx: int, name: str) -> bool:
|
|
238
|
-
class UsageVisitor(ast.NodeVisitor):
|
|
239
|
-
used: bool = False
|
|
240
|
-
|
|
241
|
-
def visit_Name(self, node: ast.Name) -> None:
|
|
242
|
-
if node.id == name:
|
|
243
|
-
self.used = True
|
|
244
|
-
self.generic_visit(node)
|
|
245
|
-
|
|
246
|
-
visitor = UsageVisitor()
|
|
247
|
-
for st in stmts[start_idx:]:
|
|
248
|
-
visitor.visit(st)
|
|
249
|
-
if visitor.used:
|
|
250
|
-
return True
|
|
251
|
-
return False
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
# --------------------------------------------------------------------------- #
|
|
255
|
-
# Main Rule Class #
|
|
256
|
-
# --------------------------------------------------------------------------- #
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
class PreferPatternMatching(Rule):
|
|
260
|
-
"""Unified Python pattern matching and regex assignment match rule."""
|
|
261
|
-
|
|
262
|
-
id: str = "prefer-pattern-matching"
|
|
263
|
-
code: str = "SARJ079"
|
|
264
|
-
description: str = (
|
|
265
|
-
"promote modern Python pattern matching idioms (or-patterns, destructuring, "
|
|
266
|
-
"exhaustiveness, and regex match assignments)."
|
|
267
|
-
)
|
|
268
|
-
|
|
269
|
-
@override
|
|
270
|
-
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
271
|
-
tree = parse_or_none(path, source)
|
|
272
|
-
if tree is None:
|
|
273
|
-
return []
|
|
274
|
-
|
|
275
|
-
source_lines = source.splitlines()
|
|
276
|
-
diags: list[Diagnostic] = []
|
|
277
|
-
|
|
278
|
-
# The loop body does Match-specific work *and* a generic scan of every
|
|
279
|
-
# node that has a statement body, so it needs every node, in walk order.
|
|
280
|
-
for node in nodes(tree, ast.AST):
|
|
281
|
-
if isinstance(node, ast.Match):
|
|
282
|
-
for run in _mergeable_runs(node):
|
|
283
|
-
p1 = _render_pattern(run[0].pattern)
|
|
284
|
-
p2 = _render_pattern(run[1].pattern)
|
|
285
|
-
line = run[0].pattern.lineno
|
|
286
|
-
col = run[0].pattern.col_offset + 1
|
|
287
|
-
if not is_suppressed(source_lines, line, self.code):
|
|
288
|
-
diags.append(
|
|
289
|
-
Diagnostic(
|
|
290
|
-
path=path,
|
|
291
|
-
line=line,
|
|
292
|
-
col=col,
|
|
293
|
-
code=self.code,
|
|
294
|
-
message=(
|
|
295
|
-
f"{len(run)} consecutive `case` arms repeat an identical body — merge them "
|
|
296
|
-
f"into one or-pattern (`case {p1} | {p2}:`) so the shared handling is written once."
|
|
297
|
-
),
|
|
298
|
-
)
|
|
299
|
-
)
|
|
300
|
-
diags.extend(_check_destructuring(node, path, source_lines, self.code))
|
|
301
|
-
diags.extend(_check_assert_never(node, path, source_lines, self.code))
|
|
302
|
-
|
|
303
|
-
raw_body = getattr(node, "body", None)
|
|
304
|
-
if isinstance(raw_body, list):
|
|
305
|
-
body: list[ast.stmt] = [st for st in raw_body if isinstance(st, ast.stmt)] # pyright: ignore[reportUnknownVariableType]
|
|
306
|
-
for i in range(len(body) - 1):
|
|
307
|
-
s1 = body[i]
|
|
308
|
-
s2 = body[i + 1]
|
|
309
|
-
|
|
310
|
-
if not (
|
|
311
|
-
isinstance(s1, ast.Assign) and len(s1.targets) == 1 and isinstance(s1.targets[0], ast.Name)
|
|
312
|
-
):
|
|
313
|
-
continue
|
|
314
|
-
var_name = s1.targets[0].id
|
|
315
|
-
|
|
316
|
-
if not _is_regex_call(s1.value) or not isinstance(s2, ast.If):
|
|
317
|
-
continue
|
|
318
|
-
|
|
319
|
-
if not _is_simple_truthy_test(s2.test, var_name) or _is_name_used_after(body, i + 2, var_name):
|
|
320
|
-
continue
|
|
321
|
-
|
|
322
|
-
if not is_suppressed(source_lines, s1.lineno, self.code):
|
|
323
|
-
diags.append(
|
|
324
|
-
Diagnostic(
|
|
325
|
-
path=path,
|
|
326
|
-
line=s1.lineno,
|
|
327
|
-
col=s1.col_offset + 1,
|
|
328
|
-
code=self.code,
|
|
329
|
-
message=(
|
|
330
|
-
f"Regex match pre-assignment `{var_name} = ...` before `if` — "
|
|
331
|
-
f"combine into `if ({var_name} := ...):`."
|
|
332
|
-
),
|
|
333
|
-
)
|
|
334
|
-
)
|
|
335
|
-
|
|
336
|
-
seen: set[tuple[int, int]] = set()
|
|
337
|
-
unique_diags: list[Diagnostic] = []
|
|
338
|
-
for d in diags:
|
|
339
|
-
if (d.line, d.col) not in seen:
|
|
340
|
-
seen.add((d.line, d.col))
|
|
341
|
-
unique_diags.append(d)
|
|
342
|
-
|
|
343
|
-
return sorted(unique_diags, key=lambda d: (d.line, d.col))
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
"""SARJ075: Rename a Python module stem to match its primary public export.
|
|
2
|
-
|
|
3
|
-
Semantic File Naming Rule:
|
|
4
|
-
When a Python module has a single primary public export (a sole top-level `class`
|
|
5
|
-
or `def`), its filename stem should semantically reflect that export's name in
|
|
6
|
-
`snake_case`.
|
|
7
|
-
|
|
8
|
-
Examples:
|
|
9
|
-
- File `user_data.py` containing sole `class UserAccountService:` -> rename to `user_account_service.py`.
|
|
10
|
-
- File `score_stuff.py` containing sole `def calculate_user_score():` -> rename to `calculate_user_score.py`.
|
|
11
|
-
|
|
12
|
-
Exemptions (Corpus-validated against noura-be, bulbul, fastapi, requests, pydantic, flask, trio):
|
|
13
|
-
- Framework convention filenames (`models.py`, `views.py`, `urls.py`, `settings.py`, `config.py`, `errors.py`, `exceptions.py`, `admin.py`, `serializers.py`, `base.py`).
|
|
14
|
-
- Dunder & entrypoint files (`__init__.py`, `conftest.py`, `__main__.py`, `main.py`).
|
|
15
|
-
- Test and documentation paths (`test_*.py`, `*_test.py`, `docs/`, `docs_src/`, `examples/`, `tutorials/`).
|
|
16
|
-
- Private modules starting with `_` (`_signature.py`).
|
|
17
|
-
- Entrypoint functions (`main`, `run`, `cli`, `setup`, `teardown`, `execute`, `asyncio_detailed`, `sync_detailed`).
|
|
18
|
-
- Generated source files (`is_generated_source`).
|
|
19
|
-
- Modules with multiple public classes/functions or public `UPPER_SNAKE` constants.
|
|
20
|
-
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
from __future__ import annotations
|
|
24
|
-
|
|
25
|
-
import ast
|
|
26
|
-
import re
|
|
27
|
-
from typing import TYPE_CHECKING, override
|
|
28
|
-
|
|
29
|
-
from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
|
|
30
|
-
from sarj_python_lint.rules._paths import is_generated_source, is_test_path
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if TYPE_CHECKING:
|
|
34
|
-
from pathlib import Path
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
_SKIPPED_FILENAMES = frozenset({"__init__.py", "conftest.py", "__main__.py", "main.py"})
|
|
38
|
-
|
|
39
|
-
_FRAMEWORK_CONVENTION_FILENAMES = frozenset(
|
|
40
|
-
{
|
|
41
|
-
"models.py",
|
|
42
|
-
"admin.py",
|
|
43
|
-
"apps.py",
|
|
44
|
-
"views.py",
|
|
45
|
-
"urls.py",
|
|
46
|
-
"forms.py",
|
|
47
|
-
"serializers.py",
|
|
48
|
-
"base.py",
|
|
49
|
-
"settings.py",
|
|
50
|
-
"config.py",
|
|
51
|
-
"configuration.py",
|
|
52
|
-
"errors.py",
|
|
53
|
-
"exceptions.py",
|
|
54
|
-
"conftest.py",
|
|
55
|
-
"__main__.py",
|
|
56
|
-
"__init__.py",
|
|
57
|
-
"main.py",
|
|
58
|
-
"middleware.py",
|
|
59
|
-
"tasks.py",
|
|
60
|
-
"signals.py",
|
|
61
|
-
"routing.py",
|
|
62
|
-
}
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
_GENERIC_ENTRYPOINT_FUNCTIONS = frozenset(
|
|
66
|
-
{"main", "run", "cli", "setup", "teardown", "execute", "asyncio_detailed", "sync_detailed"}
|
|
67
|
-
)
|
|
68
|
-
|
|
69
|
-
_DOCS_OR_EXAMPLES_PATTERN = re.compile(r"[/\\](docs|docs_src|examples|tutorials|samples)[/\\]|tutorial\d*|example\d*")
|
|
70
|
-
|
|
71
|
-
_ACRONYM_OVERRIDES: dict[str, str] = {"OAuth": "Oauth", "GraphQL": "Graphql", "gRPC": "Grpc"}
|
|
72
|
-
_CAMEL_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def _snake_case(name: str) -> str:
|
|
76
|
-
for camel, replacement in _ACRONYM_OVERRIDES.items():
|
|
77
|
-
name = name.replace(camel, replacement)
|
|
78
|
-
return _CAMEL_BOUNDARY_RE.sub("_", name).lower()
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def _has_public_constant(tree: ast.Module) -> bool:
|
|
82
|
-
targets: list[ast.expr] = []
|
|
83
|
-
for stmt in tree.body:
|
|
84
|
-
match stmt:
|
|
85
|
-
case ast.Assign(targets=assigned):
|
|
86
|
-
targets.extend(assigned)
|
|
87
|
-
case ast.AnnAssign(target=target):
|
|
88
|
-
targets.append(target)
|
|
89
|
-
case _:
|
|
90
|
-
pass
|
|
91
|
-
return any(
|
|
92
|
-
isinstance(t, ast.Name) and not t.id.startswith("_") and t.id == t.id.upper() and any(c.isalpha() for c in t.id)
|
|
93
|
-
for t in targets
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
class PrimaryExportFileName(Rule):
|
|
98
|
-
"""Rename a Python module stem to match its sole primary public class/def export."""
|
|
99
|
-
|
|
100
|
-
id: str = "primary-export-file-name"
|
|
101
|
-
code: str = "SARJ075"
|
|
102
|
-
description: str = "A module with a single primary public export should be named after that export."
|
|
103
|
-
|
|
104
|
-
@override
|
|
105
|
-
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
106
|
-
if path.suffix not in {".py", ".pyi"}:
|
|
107
|
-
return []
|
|
108
|
-
if path.name in _SKIPPED_FILENAMES or path.name.lower() in _FRAMEWORK_CONVENTION_FILENAMES:
|
|
109
|
-
return []
|
|
110
|
-
if path.stem.startswith("_"):
|
|
111
|
-
return []
|
|
112
|
-
if is_test_path(path) or is_generated_source(source):
|
|
113
|
-
return []
|
|
114
|
-
if _DOCS_OR_EXAMPLES_PATTERN.search(str(path)):
|
|
115
|
-
return []
|
|
116
|
-
|
|
117
|
-
tree = parse_or_none(path, source)
|
|
118
|
-
if tree is None:
|
|
119
|
-
return []
|
|
120
|
-
|
|
121
|
-
public_defs = [
|
|
122
|
-
node
|
|
123
|
-
for node in tree.body
|
|
124
|
-
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) and not node.name.startswith("_")
|
|
125
|
-
]
|
|
126
|
-
if len(public_defs) != 1 or _has_public_constant(tree):
|
|
127
|
-
return []
|
|
128
|
-
|
|
129
|
-
primary = public_defs[0]
|
|
130
|
-
if primary.name in _GENERIC_ENTRYPOINT_FUNCTIONS:
|
|
131
|
-
return []
|
|
132
|
-
|
|
133
|
-
expected_stem = _snake_case(primary.name)
|
|
134
|
-
if path.stem == expected_stem:
|
|
135
|
-
return []
|
|
136
|
-
|
|
137
|
-
return [
|
|
138
|
-
Diagnostic(
|
|
139
|
-
path=path,
|
|
140
|
-
line=primary.lineno,
|
|
141
|
-
col=primary.col_offset + 1,
|
|
142
|
-
code=self.code,
|
|
143
|
-
message=(
|
|
144
|
-
f"module stem `{path.stem}` does not match its primary public "
|
|
145
|
-
f"export `{primary.name}` — rename the file to `{expected_stem}.py` to "
|
|
146
|
-
f"describe its responsibility."
|
|
147
|
-
),
|
|
148
|
-
)
|
|
149
|
-
]
|
|
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.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/_ast_index.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/_first_party.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.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/mock_without_spec.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_comment_cruft.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_fat_try_blocks.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.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_secret_in_log.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_select_star.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_stdlib_logging.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/over_mocked_test.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/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
|
{sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_or_pattern.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/unused_mock_setup.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|