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.
Files changed (94) hide show
  1. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/_comments.py +35 -5
  4. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/_logging.py +4 -1
  5. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/_registry.py +0 -6
  6. sarj_python_lint-0.29.0/src/sarj_python_lint/rules/_suppression_comments.py +59 -0
  7. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +40 -3
  8. sarj_python_lint-0.27.0/src/sarj_python_lint/rules/_suppression_comments.py +0 -96
  9. sarj_python_lint-0.27.0/src/sarj_python_lint/rules/prefer_pattern_matching.py +0 -343
  10. sarj_python_lint-0.27.0/src/sarj_python_lint/rules/primary_export_file_name.py +0 -149
  11. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/.gitignore +0 -0
  12. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/README.md +0 -0
  13. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/__init__.py +0 -0
  14. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/__main__.py +0 -0
  15. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  16. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/_secret_names.py +0 -0
  17. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/_version.py +0 -0
  18. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/py.typed +0 -0
  19. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/ratchet.py +0 -0
  20. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rule_base.py +0 -0
  21. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  22. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  23. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  24. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  25. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  26. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  27. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +0 -0
  28. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  29. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  30. {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
  31. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/interaction_only_test.py +0 -0
  32. {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
  33. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
  34. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  35. {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
  36. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  37. {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
  38. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  39. {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
  40. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  41. {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
  42. {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
  43. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_implicit_attribute_access.py +0 -0
  44. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  45. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  46. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  47. {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
  48. {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
  49. {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
  50. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  51. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  52. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  53. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  54. {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
  55. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  56. {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
  57. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  58. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  59. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  60. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  61. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
  62. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  63. {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
  64. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  65. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  66. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  67. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +0 -0
  68. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  69. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  70. {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
  71. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  72. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  73. {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
  74. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  75. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  76. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  77. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  78. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  79. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  80. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  81. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  82. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  83. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  84. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  85. {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
  86. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  87. {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
  88. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -0
  89. {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
  90. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  91. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  92. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  93. {sarj_python_lint-0.27.0 → sarj_python_lint-0.29.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
  94. {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.27.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.27.0"
3
+ version = "0.29.0"
4
4
  description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "sarj-ai" }]
@@ -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
- _Scan = tuple[list[tuple[int, int, str]], list[tuple[int, int, str]], set[int], int]
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
- entry = (tok.start[0], tok.start[1], tok.string.lstrip("#").strip())
416
- (trailing if tok.start[0] == prev_end_row else standalone).append(entry)
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
- _LOGGER_FACTORIES = frozenset({"getlogger", "get_logger"})
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
+ ]
@@ -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
- if isinstance(called, ast.Attribute) and called.attr == "getLogger":
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
- if isinstance(called, ast.Name) and called.id == "getLogger":
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
- ]