sarj-python-lint 0.17.0__tar.gz → 0.18.1__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 (54) hide show
  1. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rule_base.py +5 -5
  4. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/_paths.py +6 -1
  5. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +11 -0
  6. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/kwonly_same_type_params.py +34 -1
  7. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_fat_try_blocks.py +57 -42
  8. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_repeated_string_literal.py +8 -0
  9. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_sequential_await.py +2 -0
  10. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +9 -1
  11. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/prefer_str_enum.py +18 -0
  12. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +30 -1
  13. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/single_public_export.py +8 -0
  14. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/stepdown.py +10 -0
  15. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/xfail_requires_strict.py +3 -1
  16. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/.gitignore +0 -0
  17. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/README.md +0 -0
  18. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/__init__.py +0 -0
  19. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/__main__.py +0 -0
  20. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/_secret_names.py +0 -0
  21. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/_version.py +0 -0
  22. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/py.typed +0 -0
  23. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/__init__.py +0 -0
  24. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/_logging.py +0 -0
  25. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/_registry.py +0 -0
  26. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/_sql.py +0 -0
  27. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  28. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  29. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  30. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  31. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  32. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  33. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  34. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  35. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  36. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  37. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  38. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  39. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  40. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  41. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  42. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  43. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  44. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
  45. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  46. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  47. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  48. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  49. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  50. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  51. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  52. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  53. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
  54. {sarj_python_lint-0.17.0 → sarj_python_lint-0.18.1}/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.17.0
3
+ Version: 0.18.1
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.17.0"
3
+ version = "0.18.1"
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" }]
@@ -87,7 +87,7 @@ class Rule(ABC):
87
87
  raise NotImplementedError
88
88
 
89
89
 
90
- _last_parse: tuple[tuple[str, int, int], ast.Module | None] | None = None
90
+ _last_parse: tuple[str, str, ast.Module | None] | None = None
91
91
 
92
92
 
93
93
  def parse_or_none(path: Path, source: str) -> ast.Module | None:
@@ -98,12 +98,12 @@ def parse_or_none(path: Path, source: str) -> ast.Module | None:
98
98
 
99
99
  """
100
100
  global _last_parse # ruff:ignore[global-statement] — single-slot memo; the CLI runs rules per file sequentially
101
- key = (str(path), len(source), hash(source))
102
- if _last_parse is not None and _last_parse[0] == key:
103
- return _last_parse[1]
101
+ path_key = str(path)
102
+ if _last_parse is not None and _last_parse[0] == path_key and _last_parse[1] is source:
103
+ return _last_parse[2]
104
104
  try:
105
105
  tree = ast.parse(source, filename=str(path))
106
106
  except SyntaxError:
107
107
  tree = None
108
- _last_parse = (key, tree)
108
+ _last_parse = (path_key, source, tree)
109
109
  return tree
@@ -23,7 +23,12 @@ if TYPE_CHECKING:
23
23
 
24
24
  _TEST_DIR_NAMES = frozenset({"tests", "test"})
25
25
 
26
- _GENERATED_RE = re.compile(r"auto-?generated|generated by|do not edit", re.IGNORECASE)
26
+ # `auto-?generated` misses "automatically generated", which is the phrase
27
+ # @generated tooling, protoc and OpenAPI generators emit most often.
28
+ _GENERATED_RE = re.compile(
29
+ r"auto(?:-|matically )?generated|generated by|do not edit|@generated",
30
+ re.IGNORECASE,
31
+ )
27
32
 
28
33
  _GENERATED_HEADER_LINES = 5
29
34
 
@@ -34,6 +34,12 @@ References:
34
34
  - https://docs.python.org/3/library/stdtypes.html#str.join
35
35
  - https://wiki.python.org/moin/PythonSpeed/PerformanceTips
36
36
 
37
+ * **generated files** (`_paths.is_generated_source`). Their layout is the
38
+ generator's, and re-running the generator discards any edit, so a finding
39
+ there can never be acted on in place. Measured on the 69 `DO NOT EDIT`
40
+ files git-tracked across bulbul and noura-be — Speakeasy's
41
+ `python/sdk/src/sarj_platform_sdk/` accounts for all of them.
42
+
37
43
  """
38
44
 
39
45
  from __future__ import annotations
@@ -42,6 +48,7 @@ import ast
42
48
  from typing import TYPE_CHECKING, TypeGuard, override
43
49
 
44
50
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
51
+ from sarj_python_lint.rules._paths import is_generated_source
45
52
 
46
53
 
47
54
  if TYPE_CHECKING:
@@ -58,6 +65,10 @@ class InefficientStringConcatInLoop(Rule):
58
65
 
59
66
  @override
60
67
  def check(self, path: Path, source: str) -> list[Diagnostic]:
68
+ if is_generated_source(source):
69
+ return []
70
+ if "+" not in source or ("for " not in source and "while " not in source):
71
+ return []
61
72
  tree = parse_or_none(path, source)
62
73
  if tree is None:
63
74
  return []
@@ -157,16 +157,30 @@ _DUCK_PROTOCOL_METHODS = frozenset(
157
157
  #: drawn entirely from one of these reads unambiguously positionally.
158
158
  _CONVENTIONAL_ORDER_GROUPS = (
159
159
  frozenset({"x", "y", "z"}),
160
+ frozenset({"lat", "lon", "alt"}),
161
+ frozenset({"latitude", "longitude", "altitude"}),
160
162
  frozenset({"width", "height", "depth"}),
161
163
  frozenset({"red", "green", "blue", "alpha"}),
162
164
  frozenset({"row", "column"}),
163
165
  frozenset({"top", "right", "bottom", "left"}),
166
+ frozenset({"left", "right"}),
167
+ frozenset({"lo", "hi"}),
168
+ frozenset({"low", "high"}),
169
+ frozenset({"minimum", "maximum"}),
170
+ frozenset({"min_value", "max_value"}),
171
+ frozenset({"begin", "end"}),
172
+ frozenset({"source", "sink"}),
164
173
  frozenset({"year", "month", "day"}),
165
174
  frozenset({"hour", "minute", "second", "microsecond"}),
166
175
  frozenset({"start", "stop", "step"}),
167
176
  )
168
177
 
169
178
  _EXEMPT_NAME_PREFIXES = ("visit_", "test_")
179
+ _RISKY_NAME_PART_RE = re.compile(
180
+ r"(?:^|_)(?:id|key|token|secret|password|signature|hash|email|url|uri|path|file|"
181
+ r"source|src|target|dst|dest|destination|parent|child|from|to|old|new|"
182
+ r"before|after|previous|next|expected|actual|left_id|right_id)(?:_|$)"
183
+ )
170
184
 
171
185
 
172
186
  class KwonlySameTypeParams(Rule):
@@ -350,11 +364,30 @@ def _swap_prone_annotation(args: ast.arguments) -> str | None:
350
364
  for name, arg_names in sorted(groups.items(), key=lambda kv: -len(kv[1])):
351
365
  if len(arg_names) >= _MIN_SAME_TYPE and not (
352
366
  _is_symmetric_numbering(arg_names) or _is_conventional_order(arg_names)
353
- ):
367
+ ) and _is_high_value_group(name, arg_names):
354
368
  return name
355
369
  return None
356
370
 
357
371
 
372
+ def _is_high_value_group(annotation: str, arg_names: list[str]) -> bool:
373
+ """Report whether a same-primitive group is worth enforcing globally.
374
+
375
+ Booleans are always high-risk because positional `True, False` carries no
376
+ call-site meaning. Other primitives fire only when the parameter names carry
377
+ production-domain identifiers or directed relationships (`source_id`,
378
+ `target_id`, `old_key`, `new_key`, `input_path`, `output_path`). This keeps
379
+ math / algorithm APIs such as `power(base, exponent)` and `f(a, b)` out of
380
+ the default rule while preserving the bug class the rule was written for.
381
+
382
+ Returns:
383
+ True when the group should be reported.
384
+
385
+ """
386
+ if annotation == "bool":
387
+ return True
388
+ return sum(1 for name in arg_names if _RISKY_NAME_PART_RE.search(name)) >= _MIN_SAME_TYPE
389
+
390
+
358
391
  def _is_dunder_prefixed(arg: str) -> bool:
359
392
  """Report whether `arg` uses the PEP 484 positional-only naming convention.
360
393
 
@@ -69,6 +69,12 @@ References:
69
69
  - https://docs.python.org/3/tutorial/errors.html#handling-exceptions
70
70
  - https://docs.python.org/3/library/ast.html#ast.Try
71
71
 
72
+ * **generated files** (`_paths.is_generated_source`). Their layout is the
73
+ generator's, and re-running the generator discards any edit, so a finding
74
+ there can never be acted on in place. Measured on the 69 `DO NOT EDIT`
75
+ files git-tracked across bulbul and noura-be — Speakeasy's
76
+ `python/sdk/src/sarj_platform_sdk/` accounts for all of them.
77
+
72
78
  """
73
79
 
74
80
  from __future__ import annotations
@@ -79,6 +85,7 @@ from typing import TYPE_CHECKING, override
79
85
 
80
86
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
81
87
  from sarj_python_lint.rules._logging import is_logger_expr
88
+ from sarj_python_lint.rules._paths import is_generated_source
82
89
 
83
90
 
84
91
  if TYPE_CHECKING:
@@ -124,38 +131,42 @@ def _nested_scope_body_ids(node: ast.AST) -> frozenset[int]:
124
131
  #: `set` and `record` are deliberately ABSENT — `cache.set(k, v)` /
125
132
  #: `store.record(row)` collide with them and are real work whose failure a handler
126
133
  #: is plausibly written for.
127
- _OBSERVABILITY_METHODS = frozenset({
128
- "inc",
129
- "dec",
130
- "observe",
131
- "set_to_current_time",
132
- "labels",
133
- "increment",
134
- "decrement",
135
- "gauge",
136
- "timing",
137
- "histogram",
138
- "record_exception",
139
- "add_event",
140
- "set_attribute",
141
- "set_attributes",
142
- "set_status",
143
- })
134
+ _OBSERVABILITY_METHODS = frozenset(
135
+ {
136
+ "inc",
137
+ "dec",
138
+ "observe",
139
+ "set_to_current_time",
140
+ "labels",
141
+ "increment",
142
+ "decrement",
143
+ "gauge",
144
+ "timing",
145
+ "histogram",
146
+ "record_exception",
147
+ "add_event",
148
+ "set_attribute",
149
+ "set_attributes",
150
+ "set_status",
151
+ }
152
+ )
144
153
 
145
154
  #: Clock reads. `time.monotonic()` / `perf_counter()` / `time()` and
146
155
  #: `datetime.now()` / `utcnow()` cannot raise at all, but they are the calls that
147
156
  #: turn an elapsed-time bookkeeping line into a "throwing" statement.
148
157
  _CLOCK_ROOTS = frozenset({"time", "datetime", "date"})
149
- _CLOCK_METHODS = frozenset({
150
- "monotonic",
151
- "monotonic_ns",
152
- "perf_counter",
153
- "perf_counter_ns",
154
- "time",
155
- "time_ns",
156
- "now",
157
- "utcnow",
158
- })
158
+ _CLOCK_METHODS = frozenset(
159
+ {
160
+ "monotonic",
161
+ "monotonic_ns",
162
+ "perf_counter",
163
+ "perf_counter_ns",
164
+ "time",
165
+ "time_ns",
166
+ "now",
167
+ "utcnow",
168
+ }
169
+ )
159
170
 
160
171
  #: Value-shaping builtins. These are what an instrumentation line calls on its
161
172
  #: arguments — `logger.info(..., n=len(items), elapsed_s=round(t, 2))`,
@@ -163,21 +174,23 @@ _CLOCK_METHODS = frozenset({
163
174
  #: line passes them, so they don't make the statement a candidate for the
164
175
  #: handler. Builtins that genuinely do work and raise (`open`, `eval`, `next`,
165
176
  #: `getattr`) are deliberately absent.
166
- _INERT_BUILTINS = frozenset({
167
- "abs",
168
- "bool",
169
- "float",
170
- "format",
171
- "id",
172
- "int",
173
- "len",
174
- "list",
175
- "repr",
176
- "round",
177
- "str",
178
- "tuple",
179
- "type",
180
- })
177
+ _INERT_BUILTINS = frozenset(
178
+ {
179
+ "abs",
180
+ "bool",
181
+ "float",
182
+ "format",
183
+ "id",
184
+ "int",
185
+ "len",
186
+ "list",
187
+ "repr",
188
+ "round",
189
+ "str",
190
+ "tuple",
191
+ "type",
192
+ }
193
+ )
181
194
 
182
195
 
183
196
  def _attr_root(expr: ast.expr) -> str | None:
@@ -316,6 +329,8 @@ class NoFatTryBlocks(Rule):
316
329
 
317
330
  @override
318
331
  def check(self, path: Path, source: str) -> list[Diagnostic]:
332
+ if is_generated_source(source):
333
+ return []
319
334
  tree = parse_or_none(path, source)
320
335
  if tree is None:
321
336
  return []
@@ -70,6 +70,11 @@ duplicate can be suppressed per-line with `# sarj-noqa: SARJ024 — <reason>`.
70
70
 
71
71
  Skipped entirely: `conftest.py`, test files (`test_*.py` or under a `tests/`
72
72
  directory) — fixtures legitimately repeat literal payloads.
73
+ * **generated files** (`_paths.is_generated_source`). Their layout is the
74
+ generator's, and re-running the generator discards any edit, so a finding
75
+ there can never be acted on in place. Measured on the 69 `DO NOT EDIT`
76
+ files git-tracked across bulbul and noura-be — Speakeasy's
77
+ `python/sdk/src/sarj_platform_sdk/` accounts for all of them.
73
78
  """
74
79
 
75
80
  from __future__ import annotations
@@ -80,6 +85,7 @@ import re
80
85
  from typing import TYPE_CHECKING, override
81
86
 
82
87
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
88
+ from sarj_python_lint.rules._paths import is_generated_source
83
89
 
84
90
 
85
91
  if TYPE_CHECKING:
@@ -109,6 +115,8 @@ class NoRepeatedStringLiteral(Rule):
109
115
 
110
116
  @override
111
117
  def check(self, path: Path, source: str) -> list[Diagnostic]:
118
+ if is_generated_source(source):
119
+ return []
112
120
  if _is_skipped_path(path):
113
121
  return []
114
122
  tree = parse_or_none(path, source)
@@ -76,6 +76,8 @@ class NoSequentialAwait(Rule):
76
76
  def check(self, path: Path, source: str) -> list[Diagnostic]:
77
77
  if _is_test_path(path):
78
78
  return []
79
+ if "await" not in source or "for " not in source:
80
+ return []
79
81
  tree = parse_or_none(path, source)
80
82
  if tree is None:
81
83
  return []
@@ -70,6 +70,12 @@ Suppress a deliberate positional return with `# sarj-noqa: SARJ026 — <reason>`
70
70
  References:
71
71
  - https://docs.python.org/3/library/typing.html#typing.NamedTuple
72
72
 
73
+ * **generated files** (`_paths.is_generated_source`). Their layout is the
74
+ generator's, and re-running the generator discards any edit, so a finding
75
+ there can never be acted on in place. Measured on the 69 `DO NOT EDIT`
76
+ files git-tracked across bulbul and noura-be — Speakeasy's
77
+ `python/sdk/src/sarj_platform_sdk/` accounts for all of them.
78
+
73
79
  """
74
80
 
75
81
  from __future__ import annotations
@@ -80,7 +86,7 @@ from types import EllipsisType
80
86
  from typing import TYPE_CHECKING, override
81
87
 
82
88
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
83
- from sarj_python_lint.rules._paths import is_test_path
89
+ from sarj_python_lint.rules._paths import is_generated_source, is_test_path
84
90
 
85
91
 
86
92
  if TYPE_CHECKING:
@@ -140,6 +146,8 @@ class PreferNamedtupleOverTupleReturn(Rule):
140
146
 
141
147
  @override
142
148
  def check(self, path: Path, source: str) -> list[Diagnostic]:
149
+ if is_generated_source(source):
150
+ return []
143
151
  if is_test_path(path):
144
152
  return []
145
153
  tree = parse_or_none(path, source)
@@ -235,6 +235,8 @@ class PreferStrEnum(Rule):
235
235
 
236
236
  @override
237
237
  def check(self, path: Path, source: str) -> list[Diagnostic]:
238
+ if not _has_str_enum_signal(source):
239
+ return []
238
240
  tree = parse_or_none(path, source)
239
241
  if tree is None:
240
242
  return []
@@ -332,6 +334,22 @@ class PreferStrEnum(Rule):
332
334
  return diags
333
335
 
334
336
 
337
+ def _has_str_enum_signal(source: str) -> bool:
338
+ """Cheap source gate for files that cannot contain this rule's triggers.
339
+
340
+ Returns:
341
+ True when the source contains enough lexical signal to justify parsing.
342
+
343
+ """
344
+ has_string_literal = '"' in source or "'" in source
345
+ if "str" in source and any(name in source.lower() for name in CHOICES_ATTR_NAMES):
346
+ return True
347
+ return (
348
+ has_string_literal
349
+ and ("==" in source or "!=" in source or "case " in source or "match " in source)
350
+ )
351
+
352
+
335
353
  def _cluster_fires(key: str, entry: _ClusterEntry) -> bool:
336
354
  _line, _col, literals, eq_literals, ne_literals, in_literals = entry
337
355
  if not eq_literals and not ne_literals:
@@ -71,6 +71,12 @@ Suppress an intentional raw-numeric duration with `# sarj-noqa: SARJ014 — <rea
71
71
  References:
72
72
  - https://docs.python.org/3/library/datetime.html#timedelta-objects
73
73
 
74
+ * **generated files** (`_paths.is_generated_source`). Their layout is the
75
+ generator's, and re-running the generator discards any edit, so a finding
76
+ there can never be acted on in place. Measured on the 69 `DO NOT EDIT`
77
+ files git-tracked across bulbul and noura-be — Speakeasy's
78
+ `python/sdk/src/sarj_platform_sdk/` accounts for all of them.
79
+
74
80
  """
75
81
 
76
82
  from __future__ import annotations
@@ -80,7 +86,7 @@ import re
80
86
  from typing import TYPE_CHECKING, override
81
87
 
82
88
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
83
- from sarj_python_lint.rules._paths import is_test_path
89
+ from sarj_python_lint.rules._paths import is_generated_source, is_test_path
84
90
 
85
91
 
86
92
  if TYPE_CHECKING:
@@ -104,6 +110,25 @@ _EXCLUDE_RE = re.compile(
104
110
  )
105
111
 
106
112
  _NUMERIC_NAMES = frozenset({"int", "float"})
113
+ _BARE_UNIT_NAMES = frozenset(
114
+ {
115
+ "day",
116
+ "days",
117
+ "hour",
118
+ "hours",
119
+ "minute",
120
+ "minutes",
121
+ "min",
122
+ "mins",
123
+ "second",
124
+ "seconds",
125
+ "sec",
126
+ "secs",
127
+ "millisecond",
128
+ "milliseconds",
129
+ "ms",
130
+ }
131
+ )
107
132
 
108
133
  #: Roots of the CLI frameworks whose decorators bind a parameter to an argv value.
109
134
  _CLI_MODULES = frozenset({"click", "typer"})
@@ -138,6 +163,8 @@ class PreferTimedeltaForDurations(Rule):
138
163
 
139
164
  @override
140
165
  def check(self, path: Path, source: str) -> list[Diagnostic]:
166
+ if is_generated_source(source):
167
+ return []
141
168
  if is_test_path(path):
142
169
  return []
143
170
  tree = parse_or_none(path, source)
@@ -172,6 +199,8 @@ class PreferTimedeltaForDurations(Rule):
172
199
  ) -> None:
173
200
  if annotation is None:
174
201
  return
202
+ if name.lower() in _BARE_UNIT_NAMES or name.lower().endswith(("_worked", "_elapsed")):
203
+ return
175
204
  if not _UNIT_RE.search(name) or _EXCLUDE_RE.search(name):
176
205
  return
177
206
  numeric = _numeric_annotation(annotation)
@@ -39,6 +39,11 @@ a `tests/` directory), and framework-convention filenames whose stem is fixed by
39
39
  a framework/tool and cannot be renamed (`models.py`, `views.py`, `base.py`, ...).
40
40
  Modules whose single export already snake-cases to the stem are not flagged
41
41
  (there is nothing to improve).
42
+ * **generated files** (`_paths.is_generated_source`). Their layout is the
43
+ generator's, and re-running the generator discards any edit, so a finding
44
+ there can never be acted on in place. Measured on the 69 `DO NOT EDIT`
45
+ files git-tracked across bulbul and noura-be — Speakeasy's
46
+ `python/sdk/src/sarj_platform_sdk/` accounts for all of them.
42
47
  """
43
48
 
44
49
  from __future__ import annotations
@@ -48,6 +53,7 @@ import re
48
53
  from typing import TYPE_CHECKING, override
49
54
 
50
55
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
56
+ from sarj_python_lint.rules._paths import is_generated_source
51
57
 
52
58
 
53
59
  if TYPE_CHECKING:
@@ -128,6 +134,8 @@ class SinglePublicExport(Rule):
128
134
 
129
135
  @override
130
136
  def check(self, path: Path, source: str) -> list[Diagnostic]:
137
+ if is_generated_source(source):
138
+ return []
131
139
  if _is_skipped_path(path):
132
140
  return []
133
141
  if path.stem.lower() not in _JUNK_DRAWER_STEMS:
@@ -58,6 +58,11 @@ Never fires on:
58
58
  `_code_str`). Siblings are excluded — an identically-named sibling method is a
59
59
  different method. Callers in classes outside the module remain invisible to
60
60
  syntactic analysis.
61
+ * **generated files** (`_paths.is_generated_source`). Their layout is the
62
+ generator's, and re-running the generator discards any edit, so a finding
63
+ there can never be acted on in place. Measured on the 69 `DO NOT EDIT`
64
+ files git-tracked across bulbul and noura-be — Speakeasy's
65
+ `python/sdk/src/sarj_platform_sdk/` accounts for all of them.
61
66
  """
62
67
 
63
68
  from __future__ import annotations
@@ -67,6 +72,7 @@ from collections import Counter
67
72
  from typing import TYPE_CHECKING, override
68
73
 
69
74
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
75
+ from sarj_python_lint.rules._paths import is_generated_source
70
76
 
71
77
 
72
78
  if TYPE_CHECKING:
@@ -105,8 +111,12 @@ class Stepdown(Rule):
105
111
 
106
112
  @override
107
113
  def check(self, path: Path, source: str) -> list[Diagnostic]:
114
+ if is_generated_source(source):
115
+ return []
108
116
  if _is_test_path(path):
109
117
  return []
118
+ if "def _" not in source and "async def _" not in source:
119
+ return []
110
120
  tree = parse_or_none(path, source)
111
121
  if tree is None:
112
122
  return []
@@ -189,7 +189,9 @@ def _is_rotting_xfail(dec: ast.expr) -> bool:
189
189
  _ENV_PROBE_MODULES = frozenset({"sys", "os", "platform", "sysconfig"})
190
190
 
191
191
  # Attribute / name words that identify an interpreter- or OS-version probe.
192
- _ENV_PROBE_RE = re.compile(r"version|implementation|platform|machine|pypy|jython|win32|windows|linux|darwin|macos", re.IGNORECASE)
192
+ _ENV_PROBE_RE = re.compile(
193
+ r"version|implementation|platform|machine|pypy|jython|win32|windows|linux|darwin|macos", re.IGNORECASE
194
+ )
193
195
 
194
196
 
195
197
  def _is_environment_gated(dec: ast.Call) -> bool: