sarj-python-lint 0.28.0__tar.gz → 0.30.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 (91) hide show
  1. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/_logging.py +4 -1
  4. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -10
  5. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +40 -3
  6. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -29
  7. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_implicit_attribute_access.py +52 -1
  8. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +109 -35
  9. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/.gitignore +0 -0
  10. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/README.md +0 -0
  11. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/__init__.py +0 -0
  12. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/__main__.py +0 -0
  13. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  14. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/_secret_names.py +0 -0
  15. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/_version.py +0 -0
  16. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/py.typed +0 -0
  17. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/ratchet.py +0 -0
  18. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rule_base.py +0 -0
  19. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  20. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  21. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  22. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  23. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  24. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  25. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/_registry.py +0 -0
  26. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  27. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +0 -0
  28. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  29. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  30. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  31. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/interaction_only_test.py +0 -0
  32. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  33. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
  34. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  35. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  36. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  37. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  38. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  39. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  40. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  41. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  42. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  43. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  44. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  45. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_patching_system_under_test.py +0 -0
  46. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  47. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  48. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  49. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  50. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  51. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  52. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  53. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  54. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  55. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  56. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  57. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  58. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  59. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
  60. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  61. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  62. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  63. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  64. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  65. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +0 -0
  66. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  67. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  68. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  69. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  70. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +0 -0
  71. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  72. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  73. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  74. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  75. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  76. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  77. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  78. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  79. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  80. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  81. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  82. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  83. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  84. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  85. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -0
  86. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
  87. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  88. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  89. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  90. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
  91. {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.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.28.0
3
+ Version: 0.30.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.28.0"
3
+ version = "0.30.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" }]
@@ -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:
@@ -57,13 +57,3 @@ def scan_comments(source: str) -> list[Comment]:
57
57
  )
58
58
  for line, col, body, standalone in ordered
59
59
  ]
60
-
61
-
62
- def comment_body(raw: str) -> str:
63
- """Strip a comment token down to its directive text.
64
-
65
- Returns:
66
- The comment text without its leading `#` markers or surrounding space.
67
-
68
- """
69
- return raw.lstrip("#").strip()
@@ -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):
@@ -66,11 +66,6 @@ if TYPE_CHECKING:
66
66
 
67
67
  _GEN_RANDOM_UUID_RE = re.compile(r"\bgen_random_uuid\s*\(", re.IGNORECASE)
68
68
 
69
- # The whole-source gate in `check`. Deliberately weaker than the pattern above —
70
- # the identifier alone, no paren — so it cannot exclude a file whose masked SQL
71
- # would have matched. See the comment at its use site.
72
- _NAMES_GEN_RANDOM_UUID_RE = re.compile(r"gen_random_uuid", re.IGNORECASE)
73
-
74
69
  # A string only counts as SQL when it carries a structural keyword. Without this
75
70
  # the rule fires on prose that names the function — including this module's own
76
71
  # docstring.
@@ -116,30 +111,6 @@ class NoGenRandomUuidInSql(Rule):
116
111
  The diagnostics, sorted by (line, col).
117
112
 
118
113
  """
119
- # Naming the function is a NECESSARY condition for any finding: the
120
- # match runs against `strip_sql_noise(literal)`, and that only ever
121
- # removes characters, so text absent from the file cannot appear in the
122
- # masked literal. Checking the raw source first skips the parse and the
123
- # per-literal SQL masking for the ~100% of files that never mention it.
124
- #
125
- # This rule is not store-gated and so masked every string literal in
126
- # every file, which made it the worst cost-per-finding in the registry
127
- # (2 findings across 21 corpora, at ~125 ms each). The gate removes that
128
- # for no loss of coverage — verified by measuring the corpus before and
129
- # after, not by inspection.
130
- #
131
- # The gate deliberately tests only the identifier, NOT the full
132
- # `_GEN_RANDOM_UUID_RE`, which also requires the open paren. Masking can
133
- # delete characters *between* the name and its paren — `gen_random_uuid
134
- # /* c */ ()` masks to `gen_random_uuid ()` and matches — so gating on
135
- # the stricter pattern would drop a finding the rule would otherwise
136
- # report. A gate must be strictly weaker than the predicate it guards.
137
- #
138
- # It is case-insensitive for the same reason `_GEN_RANDOM_UUID_RE` is: a
139
- # `DEFAULT GEN_RANDOM_UUID()` in upper-case SQL is a real finding, and a
140
- # plain `"gen_random_uuid" in source` test would silently drop it.
141
- if not _NAMES_GEN_RANDOM_UUID_RE.search(source):
142
- return []
143
114
  if is_generated_source(source):
144
115
  return []
145
116
  tree = parse_or_none(path, source)
@@ -44,6 +44,34 @@ _EXCLUDED_BASES = {
44
44
  }
45
45
 
46
46
 
47
+ # Typing constructs subscripted with string literals. `Literal["x"]` is a type,
48
+ # not a lookup, so the rule's advice ("parse declaratively with Pydantic") is
49
+ # nonsensical there -- the annotation already IS the declarative schema.
50
+ _TYPE_SUBSCRIPTS = frozenset(
51
+ {
52
+ "Literal",
53
+ "Annotated",
54
+ "TypedDict",
55
+ "NamedTuple",
56
+ "Field",
57
+ "Doc",
58
+ "Required",
59
+ "NotRequired",
60
+ "ReadOnly",
61
+ }
62
+ )
63
+
64
+
65
+ def _looks_like_route_or_url(value: str) -> bool:
66
+ """Report whether a `.get()` argument is a route path or URL rather than a key.
67
+
68
+ Returns:
69
+ True for `"/users/{id}"`-shaped paths and anything carrying a scheme.
70
+
71
+ """
72
+ return value.startswith("/") or "://" in value
73
+
74
+
47
75
  def _get_base_name(node: ast.expr) -> str | None:
48
76
  if isinstance(node, ast.Name):
49
77
  return node.id
@@ -100,6 +128,14 @@ def _get_key(node: ast.Call) -> str | None:
100
128
  first = node.args[0]
101
129
  if not isinstance(first, ast.Constant) or not isinstance(first.value, str):
102
130
  return None
131
+ # `.get()` is also the HTTP verb and the route-registration decorator, and
132
+ # both take a string first argument, so the method name alone cannot tell
133
+ # them from a mapping lookup. The ARGUMENT can: a URL or a route path is not
134
+ # a dictionary key. Measured on bulbul + noura-be, this shape was 168 of the
135
+ # rule's 1,756 findings (9.6%) -- `@router.get("/available-events")` and
136
+ # `await self.http_client.get(url)` were reported as implicit schema access.
137
+ if _looks_like_route_or_url(first.value):
138
+ return None
103
139
  return None if _get_base_name(func.value) in _EXCLUDED_BASES else first.value
104
140
 
105
141
 
@@ -111,10 +147,25 @@ def _subscript_key(node: ast.Subscript) -> str | None:
111
147
  the base is one of the excluded receivers.
112
148
 
113
149
  """
150
+ # Writing to a mapping is the opposite of the defect. This rule is about
151
+ # PLUCKING fields out of a payload whose schema is already known -- building
152
+ # a dict up key by key (`field_dict["x"] = x`, `params["status"] = ...`) is
153
+ # ordinary construction, and a Pydantic model does not replace it. Measured
154
+ # on bulbul + noura-be this was 503 of 1,756 findings (28.6%), the single
155
+ # largest source, and every sampled instance was an assignment target.
156
+ if isinstance(node.ctx, (ast.Store, ast.Del)):
157
+ return None
158
+ # `Literal["a"]`, `Annotated[T, "..."]` and friends are type expressions that
159
+ # merely LOOK like subscripts. They are not dictionary access at all, and no
160
+ # Pydantic model can replace them -- `Literal["user"]` IS the schema. 470 of
161
+ # 1,756 findings (26.8%) were this, second only to assignment targets.
162
+ base_name = _get_base_name(node.value)
163
+ if base_name in _TYPE_SUBSCRIPTS:
164
+ return None
114
165
  index = node.slice
115
166
  if not isinstance(index, ast.Constant) or not isinstance(index.value, str):
116
167
  return None
117
- return None if _get_base_name(node.value) in _EXCLUDED_BASES else index.value
168
+ return None if base_name in _EXCLUDED_BASES else index.value
118
169
 
119
170
 
120
171
  def _is_test_path(path: Path) -> bool:
@@ -1,38 +1,12 @@
1
- """SARJ080: prefer match/case over sequential sentinel/type guards.
1
+ """SARJ080: prefer match/case over control-flow try/raise and sequential type guards.
2
2
 
3
3
  Parsers and field deserializers often contain hideous type-dispatch idioms:
4
- sequential `if x is None: return x` / `if isinstance(x, Unset): return x` guards
5
- walking a value through one shape at a time, where a single `match` states the
6
- whole dispatch at once.
7
-
8
- DELIBERATELY NOT FLAGGED: `raise` inside a `try` block
9
- ------------------------------------------------------
10
-
11
- This rule used to carry a second detector for the other half of that idiom —
12
- raising inside a `try` purely to jump into its own `except` handler, i.e. using
13
- `raise` as a goto. That detector was removed because ruff already reports it and
14
- our shipped config already enables it.
15
-
16
- `ruff.strict.toml` selects `ALL` and does not ignore `TRY`, so `TRY301`
17
- (`raise-within-try`, "Abstract `raise` to an inner function") is live in every
18
- consumer. Measured across 21 corpora, the removed arm produced 1,756 findings of
19
- which 1,649 (93.9%) sat on a line ruff already flagged, and construction
20
- confirms the columns match exactly, not merely the line:
21
-
22
- try:
23
- if not isinstance(x, str):
24
- raise TypeError # ruff TRY301 at 13:13, old SARJ080 at 13:13
25
-
26
- The 107 positions TRY301 structurally misses are `raise`s inside an `except`
27
- body caught by an *outer* `try`. That is a real gap, but not one worth 1,649
28
- double reports — and 5 of those positions already carry an explicit
29
- `# noqa: TRY301`, a decision the team made that a second code would quietly
30
- re-open.
31
-
32
- The surviving sequential-guard detector has NO ruff counterpart: the same sweep
33
- found 0 of its 476 positions shared with TRY301, and `RET505` is the closest
34
- thing ruff has, firing only on `elif`-after-`return` rather than on separate
35
- `if ...: return` statements.
4
+ sequential `if x is None: return x` / `if isinstance(x, Unset): return x` guards,
5
+ followed by a `try` block containing `if not isinstance(x, T): raise TypeError()`
6
+ to artificially jump control flow into an `except (TypeError, ...): pass` block.
7
+
8
+ Raising an exception inside a `try` block solely to trigger that block's `except`
9
+ handler is using `raise` as a goto (control flow via exceptions).
36
10
 
37
11
  Preferred Python 3.10+ match/case patterns:
38
12
  - For `None`: `case None:`
@@ -68,9 +42,59 @@ if TYPE_CHECKING:
68
42
  from pathlib import Path
69
43
 
70
44
 
45
+ _GENERIC_EXCEPTIONS = frozenset({"Exception", "BaseException"})
71
46
  _MIN_SENTINEL_COUNT = 2
72
47
 
73
48
 
49
+ def _get_caught_exception_names(handlers: list[ast.ExceptHandler]) -> set[str] | None:
50
+ """Extract caught exception class names.
51
+
52
+ Returns:
53
+ A set of exception class names, or None if a catch-all handler (bare `except:`) is present.
54
+
55
+ """
56
+ caught: set[str] = set()
57
+ for h in handlers:
58
+ if h.type is None:
59
+ return None
60
+ if isinstance(h.type, ast.Name):
61
+ caught.add(h.type.id)
62
+ elif isinstance(h.type, ast.Tuple):
63
+ for elt in h.type.elts:
64
+ if isinstance(elt, ast.Name):
65
+ caught.add(elt.id)
66
+ elif isinstance(elt, ast.Attribute):
67
+ caught.add(elt.attr)
68
+ caught.add(ast.unparse(elt))
69
+ elif isinstance(h.type, ast.Attribute):
70
+ caught.add(h.type.attr)
71
+ caught.add(ast.unparse(h.type))
72
+ return caught
73
+
74
+
75
+ def _raised_exception_name(raise_node: ast.Raise) -> str | None:
76
+ """Extract exception class name from `raise Exc()` or `raise Exc`.
77
+
78
+ Returns:
79
+ The exception class name, or None if no exception is specified.
80
+
81
+ """
82
+ exc = raise_node.exc
83
+ if exc is None:
84
+ return None
85
+ if isinstance(exc, ast.Call):
86
+ func = exc.func
87
+ if isinstance(func, ast.Name):
88
+ return func.id
89
+ if isinstance(func, ast.Attribute):
90
+ return func.attr
91
+ elif isinstance(exc, ast.Name):
92
+ return exc.id
93
+ elif isinstance(exc, ast.Attribute):
94
+ return exc.attr
95
+ return None
96
+
97
+
74
98
  def _guard_target_var_name(stmt: ast.stmt) -> str | None:
75
99
  """Extract target variable name if statement is `if x is None: return ...` or `if isinstance(x, ...): return ...`.
76
100
 
@@ -167,25 +191,75 @@ class _TypeDispatchVisitor(ast.NodeVisitor):
167
191
  self.path: Path = path
168
192
  self.code: str = code
169
193
  self.diags: list[Diagnostic] = []
194
+ self.try_stack: list[set[str] | None] = []
195
+
196
+ def visit_Try(self, node: ast.Try | ast.TryStar) -> None:
197
+ caught = _get_caught_exception_names(node.handlers)
198
+ self.try_stack.append(caught)
199
+ for stmt in node.body:
200
+ self.visit(stmt)
201
+ self.try_stack.pop()
202
+ for handler in node.handlers:
203
+ self.visit(handler)
204
+ for stmt in node.orelse:
205
+ self.visit(stmt)
206
+ for stmt in node.finalbody:
207
+ self.visit(stmt)
208
+
209
+ @override
210
+ def visit_TryStar(self, node: ast.TryStar) -> None:
211
+ self.visit_Try(node)
212
+
213
+ def visit_Raise(self, node: ast.Raise) -> None:
214
+ if self.try_stack:
215
+ exc_name = _raised_exception_name(node)
216
+ if exc_name is not None:
217
+ # Search outward through active try scopes
218
+ for caught in reversed(self.try_stack):
219
+ is_caught = caught is None or exc_name in caught or bool(caught & _GENERIC_EXCEPTIONS)
220
+ if is_caught:
221
+ self.diags.append(
222
+ Diagnostic(
223
+ path=self.path,
224
+ line=node.lineno,
225
+ col=node.col_offset + 1,
226
+ code=self.code,
227
+ message=(
228
+ f"Control-flow raise in try block — 'raise {exc_name}()' "
229
+ f"jumps directly to local except handler. Refactor to 'match/case' "
230
+ f"(e.g., 'case str():') to handle types directly."
231
+ ),
232
+ )
233
+ )
234
+ break
235
+ self.generic_visit(node)
170
236
 
171
237
  def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
172
238
  self.diags.extend(_check_sequential_type_guards(node, self.path, self.code))
239
+ saved_stack = self.try_stack
240
+ self.try_stack = []
173
241
  self.generic_visit(node)
242
+ self.try_stack = saved_stack
174
243
 
175
244
  def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
176
245
  self.diags.extend(_check_sequential_type_guards(node, self.path, self.code))
246
+ saved_stack = self.try_stack
247
+ self.try_stack = []
177
248
  self.generic_visit(node)
249
+ self.try_stack = saved_stack
178
250
 
179
251
  def visit_ClassDef(self, node: ast.ClassDef) -> None:
180
252
  self.generic_visit(node)
181
253
 
182
254
 
183
255
  class PreferMatchTypeDispatch(Rule):
184
- """Prefer match/case over sequential sentinel/type guards."""
256
+ """Prefer match/case over try/raise control flow and sequential type guards."""
185
257
 
186
258
  id: str = "prefer-match-type-dispatch"
187
259
  code: str = "SARJ080"
188
- description: str = "Sequential sentinel/type guards — prefer Python 3.10+ match/case pattern matching."
260
+ description: str = (
261
+ "Control-flow raise in try block or sequential type guards — prefer Python 3.10+ match/case pattern matching."
262
+ )
189
263
 
190
264
  @override
191
265
  def check(self, path: Path, source: str) -> list[Diagnostic]: