sarj-python-lint 0.30.0__tar.gz → 0.32.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 (92) hide show
  1. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/pyproject.toml +1 -1
  3. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/_paths.py +173 -0
  4. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/duplicate_test_body.py +3 -3
  5. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +3 -3
  6. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +3 -3
  7. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/mock_without_spec.py +127 -3
  8. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_comment_cruft.py +3 -3
  9. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +3 -3
  10. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +2 -2
  11. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +3 -3
  12. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_restated_comment.py +2 -2
  13. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +2 -2
  14. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +3 -3
  15. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +14 -2
  16. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +3 -3
  17. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +3 -3
  18. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +2 -6
  19. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +3 -3
  20. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/redundant_docstring.py +2 -2
  21. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/require_port_for_service.py +2 -2
  22. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/single_public_export.py +3 -3
  23. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/stepdown.py +3 -3
  24. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/trailing_value_narration.py +2 -2
  25. sarj_python_lint-0.30.0/src/sarj_python_lint/rules/_paths.py +0 -63
  26. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/.gitignore +0 -0
  27. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/README.md +0 -0
  28. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/__init__.py +0 -0
  29. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/__main__.py +0 -0
  30. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  31. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/_secret_names.py +0 -0
  32. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/_version.py +0 -0
  33. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/py.typed +0 -0
  34. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/ratchet.py +0 -0
  35. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rule_base.py +0 -0
  36. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  37. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  38. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  39. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  40. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  41. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  42. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/_registry.py +0 -0
  43. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  44. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  45. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +0 -0
  46. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  47. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/interaction_only_test.py +0 -0
  48. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  49. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  50. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  51. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  52. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  53. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  54. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  55. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_implicit_attribute_access.py +0 -0
  56. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  57. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  58. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  59. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_patching_system_under_test.py +0 -0
  60. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  61. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  62. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  63. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  64. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  65. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  66. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  67. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  68. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  69. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  70. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
  71. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  72. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  73. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  74. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  75. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +0 -0
  76. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  77. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +0 -0
  78. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  79. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  80. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  81. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  82. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  83. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  84. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  85. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  86. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  87. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -0
  88. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
  89. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  90. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  91. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
  92. {sarj_python_lint-0.30.0 → sarj_python_lint-0.32.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.30.0
3
+ Version: 0.32.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.30.0"
3
+ version = "0.32.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" }]
@@ -0,0 +1,173 @@
1
+ """Shared file-scope predicates for the file-scope-gated rules.
2
+
3
+ A single definition of "is this a test file?" so the test-scoped rules
4
+ (SARJ031 no-sleep-in-test-body, SARJ036 no-raw-sql-in-tests) and the rules
5
+ that *exempt* tests (SARJ014 prefer-timedelta-for-durations, SARJ015
6
+ prefer-struct-over-namedtuple, SARJ026 prefer-namedtuple-over-tuple-return,
7
+ SARJ034 kwonly-same-type-params) never diverge on what counts as a test file.
8
+
9
+ Also the single definition of "is this a generated file?" (SARJ016/034):
10
+ generated code mirrors whatever its generator emits, so style rules cannot be
11
+ acted on there.
12
+
13
+ That definition is deliberately two-sided. `is_generated_source` reads the
14
+ header, which works for protoc/`@generated` tooling but is useless for the
15
+ generators that emit no banner at all. Measured on a checked-in
16
+ openapi-python-client SDK: 240 generated modules, *zero* carrying a marker in
17
+ their first five lines. `is_generated_path` covers those by looking at where
18
+ the file sits instead of what it says. `is_generated` is the predicate rules
19
+ should call — it is the union, and neither half alone is enough.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from functools import cache
25
+ import re
26
+ from typing import TYPE_CHECKING
27
+
28
+
29
+ if TYPE_CHECKING:
30
+ from pathlib import Path
31
+
32
+
33
+ _TEST_DIR_NAMES = frozenset({"tests", "test"})
34
+
35
+ # `auto-?generated` misses "automatically generated", which is the phrase
36
+ # @generated tooling, protoc and OpenAPI generators emit most often.
37
+ _GENERATED_RE = re.compile(
38
+ r"auto(?:-|matically )?generated|generated by|do not edit|@generated",
39
+ re.IGNORECASE,
40
+ )
41
+
42
+ _GENERATED_HEADER_LINES = 5
43
+
44
+ _GENERATED_DIR_NAMES = frozenset({"generated", "vendor", "vendored"})
45
+
46
+ # A directory holding one of these is a code-generator root: the file is the
47
+ # generator's own config or ignore list, and the emitted package is checked in
48
+ # beneath it. Every entry is a filename published by a code generator —
49
+ # OpenAPI Generator, swagger-codegen, openapi-python-client, buf, and
50
+ # graphql-code-generator's `codegen.*` family. None is specific to any one
51
+ # repository's layout.
52
+ _CODEGEN_MARKER_NAMES = (
53
+ ".openapi-generator",
54
+ ".openapi-generator-ignore",
55
+ ".swagger-codegen-ignore",
56
+ "openapi-python-client.yml",
57
+ "openapi-python-client.yaml",
58
+ "codegen.yml",
59
+ "codegen.yaml",
60
+ "codegen.config.yml",
61
+ "codegen.config.yaml",
62
+ "buf.gen.yaml",
63
+ "buf.gen.yml",
64
+ )
65
+
66
+ # Ancestor walks stop at the repository root. Without this a file two levels
67
+ # deep would stat every directory up to `/` looking for a marker.
68
+ _REPO_ROOT_MARKERS = (".git",)
69
+
70
+ # Guards against a pathological path (or a symlink loop) turning the ancestor
71
+ # walk into an unbounded stat storm. No real source tree is this deep.
72
+ _MAX_ANCESTOR_DEPTH = 40
73
+
74
+
75
+ def is_generated_source(source: str) -> bool:
76
+ """Report whether `source` self-identifies as generated code.
77
+
78
+ Checks the first few lines for the conventional markers
79
+ ("autogenerated", "generated by", "do not edit").
80
+
81
+ Returns:
82
+ True when the file header carries a generated-code marker.
83
+
84
+ """
85
+ head = source.splitlines()[:_GENERATED_HEADER_LINES]
86
+ return any(_GENERATED_RE.search(line) for line in head)
87
+
88
+
89
+ @cache
90
+ def _is_codegen_root(directory: Path) -> bool:
91
+ """Report whether `directory` holds a code generator's config or ignore file.
92
+
93
+ Cached: a corpus run asks this of the same handful of directories once per
94
+ file, and the answer is a filesystem probe.
95
+
96
+ Returns:
97
+ True when the directory carries a generator marker file.
98
+
99
+ """
100
+ return any((directory / name).exists() for name in _CODEGEN_MARKER_NAMES)
101
+
102
+
103
+ @cache
104
+ def _is_repo_root(directory: Path) -> bool:
105
+ """Report whether `directory` is a repository root.
106
+
107
+ Returns:
108
+ True when the directory carries a repository marker (`.git`).
109
+
110
+ """
111
+ return any((directory / name).exists() for name in _REPO_ROOT_MARKERS)
112
+
113
+
114
+ def is_generated_path(path: Path) -> bool:
115
+ """Report whether `path` sits in a tree of generated or vendored code.
116
+
117
+ Two signals, both about location rather than content:
118
+
119
+ * a `generated` / `vendor` / `vendored` directory anywhere in the path, and
120
+ * a code-generator root among the ancestors — a directory holding an
121
+ `.openapi-generator-ignore`, `codegen.config.yml` or similar.
122
+
123
+ The generator root itself stays linted: the config file's neighbours are
124
+ the hand-written driver script that invokes the generator, while the
125
+ emitted package is checked in one level down. Only the subtree below the
126
+ root is treated as generated.
127
+
128
+ Returns:
129
+ True when the file is machine-written output rather than source.
130
+
131
+ """
132
+ if any(part.lower() in _GENERATED_DIR_NAMES for part in path.parts):
133
+ return True
134
+ for depth, ancestor in enumerate(path.parents):
135
+ if depth >= _MAX_ANCESTOR_DEPTH:
136
+ break
137
+ # `depth == 0` is the file's own directory: a generator config sitting
138
+ # next to the file makes the file the generator, not its output.
139
+ if depth and _is_codegen_root(ancestor):
140
+ return True
141
+ if _is_repo_root(ancestor):
142
+ break
143
+ return False
144
+
145
+
146
+ def is_generated(path: Path, source: str) -> bool:
147
+ """Report whether the file is generated, by either header or location.
148
+
149
+ The predicate every rule that exempts generated code should call. Checking
150
+ only the header misses banner-less generators; checking only the path
151
+ misses a generated file dropped into a hand-written tree.
152
+
153
+ Returns:
154
+ True when the file is machine-written output rather than source.
155
+
156
+ """
157
+ return is_generated_source(source) or is_generated_path(path)
158
+
159
+
160
+ def is_test_path(path: Path) -> bool:
161
+ """Report whether `path` is a test file.
162
+
163
+ A test file is `conftest.py`, a `test_*.py` / `*_test.py` stem, or any file
164
+ under a `tests` / `test` directory segment.
165
+
166
+ Returns:
167
+ True when `path` belongs to the test tree.
168
+
169
+ """
170
+ name = path.name
171
+ if name == "conftest.py" or name.startswith("test_") or name.endswith("_test.py"):
172
+ return True
173
+ return any(part in _TEST_DIR_NAMES for part in path.parts)
@@ -182,7 +182,7 @@ Deliberately NOT flagged:
182
182
  * **short bodies.** A two-statement test is not evidence of copy-paste — call,
183
183
  assert, done, and any two tests of the same helper look alike. The 3-statement
184
184
  floor is what keeps the rule from firing on every well-factored unit suite,
185
- * **generated test modules** (`is_generated_source`) — a generator emitting N
185
+ * **generated test modules** (`_paths.is_generated`) — a generator emitting N
186
186
  near-identical cases is the generator's business, and the fix would be
187
187
  overwritten on the next regeneration,
188
188
  * the **first** member of a group. The diagnostic goes on the copies and points
@@ -200,7 +200,7 @@ from typing import TYPE_CHECKING, override
200
200
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
201
201
  from sarj_python_lint.rules._ast_index import children
202
202
  from sarj_python_lint.rules._comments import standalone_comments, trailing_comments
203
- from sarj_python_lint.rules._paths import is_generated_source, is_test_path
203
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
204
204
 
205
205
 
206
206
  if TYPE_CHECKING:
@@ -286,7 +286,7 @@ class DuplicateTestBody(Rule):
286
286
  by position.
287
287
 
288
288
  """
289
- if not is_test_path(path) or is_generated_source(source):
289
+ if not is_test_path(path) or is_generated(path, source):
290
290
  return []
291
291
  tree = parse_or_none(path, source)
292
292
  if tree is None:
@@ -34,7 +34,7 @@ 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
37
+ * **generated files** (`_paths.is_generated`). Their layout is the
38
38
  generator's, and re-running the generator discards any edit, so a finding
39
39
  there can never be acted on in place. Measured on the 69 `DO NOT EDIT`
40
40
  files git-tracked across bulbul and noura-be — Speakeasy's
@@ -49,7 +49,7 @@ from typing import TYPE_CHECKING, TypeGuard, override
49
49
 
50
50
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
51
51
  from sarj_python_lint.rules._ast_index import children, walk
52
- from sarj_python_lint.rules._paths import is_generated_source
52
+ from sarj_python_lint.rules._paths import is_generated
53
53
 
54
54
 
55
55
  if TYPE_CHECKING:
@@ -66,7 +66,7 @@ class InefficientStringConcatInLoop(Rule):
66
66
 
67
67
  @override
68
68
  def check(self, path: Path, source: str) -> list[Diagnostic]:
69
- if is_generated_source(source):
69
+ if is_generated(path, source):
70
70
  return []
71
71
  if "+" not in source or ("for " not in source and "while " not in source):
72
72
  return []
@@ -42,7 +42,7 @@ Never flags — these signatures cannot or should not change:
42
42
  `black/scripts/diff_shades_gha_helper.py:167`),
43
43
  * test files (`_paths.is_test_path`) — test fakes and helpers mirror the
44
44
  signatures of the code under test and cannot unilaterally change them,
45
- * generated files (`_paths.is_generated_source`) — the signature mirrors
45
+ * generated files (`_paths.is_generated`) — the signature mirrors
46
46
  whatever the generator emits (found via trio's `_generated_io_kqueue.py`),
47
47
  * functions whose name is referenced as a VALUE anywhere in the module
48
48
  (passed to a registry, returned, stored) — the signature is a callback
@@ -101,7 +101,7 @@ from typing import TYPE_CHECKING, override
101
101
 
102
102
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
103
103
  from sarj_python_lint.rules._ast_index import nodes, walk
104
- from sarj_python_lint.rules._paths import is_generated_source, is_test_path
104
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
105
105
 
106
106
 
107
107
  if TYPE_CHECKING:
@@ -196,7 +196,7 @@ class KwonlySameTypeParams(Rule):
196
196
 
197
197
  @override
198
198
  def check(self, path: Path, source: str) -> list[Diagnostic]:
199
- if is_test_path(path) or is_generated_source(source):
199
+ if is_test_path(path) or is_generated(path, source):
200
200
  return []
201
201
  tree = parse_or_none(path, source)
202
202
  if tree is None:
@@ -38,7 +38,9 @@ Deliberately NOT flagged:
38
38
  * `create_autospec(...)`, `mock.ANY`, `mock.sentinel`, `mock.call` — specced by
39
39
  construction or not doubles at all,
40
40
  * bare `Mock` referenced without being called (annotations, `isinstance`
41
- checks) — only a construction can carry a spec argument.
41
+ checks) — only a construction can carry a spec argument,
42
+ * a canned callable stub bound to an attribute (`receiver.method =
43
+ AsyncMock(return_value=...)`) — see exemption 4 below.
42
44
 
43
45
  The import-backed name check is the load-bearing false-positive guard. Test
44
46
  suites routinely define their own `Mock`-suffixed fakes (`MockVisionBankClient`,
@@ -92,6 +94,45 @@ of this rule reported 137 hits, of which 38 were false positives:
92
94
  constructions lexically inside an `ImportError`/`ModuleNotFoundError`
93
95
  handler.
94
96
 
97
+ 4. **A canned callable stub bound to an attribute (146 hits, 18.8%).** Measured
98
+ over the two first-party repos (bulbul + noura-be, 777 hits), the single
99
+ largest shape was `receiver.method = Mock(...)` — 283 hits, 36.4%. It is not
100
+ an unspecced *collaborator*; it replaces one callable on a receiver that
101
+ already exists, and the contract this rule protects belongs to that receiver.
102
+ Either the receiver carries `spec=` — in which case production's call through
103
+ a renamed attribute raises `AttributeError` off the specced parent and the
104
+ test fails loudly, which is exactly the rot this rule wants — or the receiver
105
+ is itself flagged here at its own construction, and reporting the leaf as
106
+ well says the same thing twice. Evidence:
107
+ `bulbul/python/agent/tests/conftest.py:167-170`, where
108
+ `backchanneler = mock.Mock(spec=Backchanneler)` is followed by
109
+ `backchanneler.start_audio = mock.AsyncMock()` / `.run = ...` / `.stop = ...`
110
+ — model spec discipline, and three findings; and
111
+ `bulbul/python/bulbul/tests/integrations/test_zoho_notifications_handler.py:86`
112
+ (`crm_service.get_record = mock.AsyncMock(return_value=record)`), where the
113
+ only thing the file reads off `crm_service.get_record` is
114
+ `assert_awaited_once_with`. `AsyncMock` dominates the shape (164 of 283)
115
+ because `Mock(spec=X)` children are not awaitable, so a specced double *must*
116
+ have its async methods stubbed this way to be usable at all.
117
+
118
+ The guard demands POSITIVE evidence of callability — a canned
119
+ `return_value=`/`side_effect=`, a mock-API read off the path
120
+ (`recv.method.assert_called_once_with(...)`), or an invocation — on top of
121
+ the absence of any domain-attribute read. Absence alone is not enough:
122
+ `agent/tests/test_for_call_settings.py:64` (`room.local_participant =
123
+ mock.Mock()`) is an object double this file never happens to walk, and it
124
+ stays flagged, as do the 60 namespace doubles the corpus does walk —
125
+ `test_for_call_settings.py:71,74,76` (`api.room` / `api.sip` / `api.egress`,
126
+ read back as `.delete_room`, `.create_sip_participant`,
127
+ `.start_room_composite_egress`) and `agent/tests/conftest.py:246-247`
128
+ (`job_context.api`, `job_context.api.sip`).
129
+
130
+ What this gives up is arity: `AsyncMock(spec=Store.get)` would reject a call
131
+ whose signature no longer matches, and the exempted stubs will not. That is
132
+ the narrower half of the defect, and it is the half `patch.object(mod,
133
+ "func")` still gets flagged for, since `autospec=True` is a one-word fix
134
+ there and there is no equivalent for a raw attribute assignment.
135
+
95
136
  The 99 survivors are true positives: unspecced `MagicMock()` doubles for real
96
137
  types (`black/tests/test_black.py:2933`, a `MagicMock()` standing in for `Path`
97
138
  and answering `.relative_to`/`.resolve`/`.is_dir`/`.is_file`), and
@@ -175,6 +216,11 @@ _MOCK_API_ATTRS = frozenset(
175
216
  # An import that failed leaves nothing importable to spec against.
176
217
  _IMPORT_FAILURES = frozenset({"ImportError", "ModuleNotFoundError"})
177
218
 
219
+ # Keywords that canned-answer a *call*. You only say what a double returns, or
220
+ # raises, when something is going to invoke it — so their presence is positive
221
+ # evidence that the double stands in for one callable rather than an object.
222
+ _CANNED_RESULT_KEYWORDS = frozenset({"return_value", "side_effect"})
223
+
178
224
 
179
225
  class MockWithoutSpec(Rule):
180
226
  """A `unittest.mock` double built with no `spec=` accepts any attribute."""
@@ -321,6 +367,9 @@ class _FileFacts:
321
367
  self.reads: dict[str, set[str]] = {}
322
368
  self.called: set[str] = set()
323
369
  self.import_fallbacks: set[ast.Call] = set()
370
+ self.attribute_target: dict[ast.Call, str] = {}
371
+ self.path_reads: dict[str, set[str]] = {}
372
+ self.path_calls: set[str] = set()
324
373
 
325
374
  @classmethod
326
375
  def from_tree(cls, tree: ast.Module) -> _FileFacts:
@@ -339,16 +388,39 @@ class _FileFacts:
339
388
  elif isinstance(node, ast.Attribute):
340
389
  if isinstance(node.value, ast.Name):
341
390
  found.reads.setdefault(node.value.id, set()).add(node.attr)
391
+ found._record_path_read(node)
342
392
  elif isinstance(node, ast.Call):
343
393
  if isinstance(node.func, ast.Name):
344
394
  found.called.add(node.func.id)
395
+ found._record_path_call(node)
345
396
  elif _catches_import_failure(node):
346
397
  found.import_fallbacks.update(child for child in walk(node) if isinstance(child, ast.Call))
347
398
  return found
348
399
 
349
400
  def _bind(self, target: ast.expr | None, value: ast.expr | None) -> None:
350
- if isinstance(target, ast.Name) and isinstance(value, ast.Call):
401
+ if not isinstance(value, ast.Call):
402
+ return
403
+ if isinstance(target, ast.Name):
351
404
  self.bound_name[value] = target.id
405
+ elif isinstance(target, ast.Attribute):
406
+ path = _dotted_path(target)
407
+ if path is not None:
408
+ self.attribute_target[value] = path
409
+
410
+ def _record_path_read(self, node: ast.Attribute) -> None:
411
+ # Load context only. `recv.method = Mock()` is a *store* through
412
+ # `recv.method`; counting it would make every stub look like a namespace
413
+ # double read back through its own name.
414
+ if not isinstance(node.ctx, ast.Load):
415
+ return
416
+ path = _dotted_path(node.value)
417
+ if path is not None:
418
+ self.path_reads.setdefault(path, set()).add(node.attr)
419
+
420
+ def _record_path_call(self, node: ast.Call) -> None:
421
+ path = _dotted_path(node.func)
422
+ if path is not None:
423
+ self.path_calls.add(path)
352
424
 
353
425
  def is_call_recorder(self, node: ast.Call) -> bool:
354
426
  """Report whether the double bound by `node` is only ever called and introspected.
@@ -368,6 +440,58 @@ class _FileFacts:
368
440
  return False
369
441
  return self.reads.get(name, set()) <= _MOCK_API_ATTRS
370
442
 
443
+ def is_method_stub(self, node: ast.Call) -> bool:
444
+ """Report whether `node` is a canned stub for one method of some receiver.
445
+
446
+ `receiver.method = AsyncMock(return_value=...)` replaces a single
447
+ callable, not an object. The contract this rule protects belongs to the
448
+ *receiver* — it is the receiver's construction that either carries
449
+ `spec=` or is itself flagged here — and a callable has no attribute
450
+ surface for `spec=` to fence. Two conditions must hold:
451
+
452
+ * nothing but the mock API is read back off the assigned path, so the
453
+ double is not standing in for an object the test walks, and
454
+ * something positively marks it as a callable — a canned
455
+ `return_value=`/`side_effect=`, an `assert_called*`-style read, or an
456
+ invocation of the path.
457
+
458
+ The second condition is what keeps `room.local_participant = Mock()`
459
+ flagged: absence of attribute reads is not evidence of callability, and
460
+ a namespace double that this file happens never to walk is exactly the
461
+ case `spec=` exists for.
462
+
463
+ Returns:
464
+ True when the double is a canned callable stub bound to an attribute.
465
+
466
+ """
467
+ path = self.attribute_target.get(node)
468
+ if path is None:
469
+ return False
470
+ seen = self.path_reads.get(path, set())
471
+ if not seen <= _MOCK_API_ATTRS:
472
+ return False
473
+ canned = any(kw.arg in _CANNED_RESULT_KEYWORDS for kw in node.keywords)
474
+ return canned or bool(seen) or path in self.path_calls
475
+
476
+
477
+ def _dotted_path(expr: ast.expr) -> str | None:
478
+ """Render a pure `name.attr.attr` chain as a dotted string.
479
+
480
+ Returns:
481
+ The dotted path, or None when the chain is rooted in anything other than
482
+ a bare name (a subscript, a call, a literal), where two occurrences of
483
+ the same text need not denote the same object.
484
+
485
+ """
486
+ parts: list[str] = []
487
+ while isinstance(expr, ast.Attribute):
488
+ parts.append(expr.attr)
489
+ expr = expr.value
490
+ if not isinstance(expr, ast.Name):
491
+ return None
492
+ parts.append(expr.id)
493
+ return ".".join(reversed(parts))
494
+
371
495
 
372
496
  def _catches_import_failure(handler: ast.ExceptHandler) -> bool:
373
497
  caught = handler.type
@@ -386,7 +510,7 @@ def _unspecced_calls(tree: ast.Module, names: _MockNames, facts: _FileFacts) ->
386
510
  label = _render_callee(node.func, symbol)
387
511
  if _has_spec_argument(node) or _has_positional_replacement(node, label):
388
512
  continue
389
- if node in facts.import_fallbacks or facts.is_call_recorder(node):
513
+ if node in facts.import_fallbacks or facts.is_call_recorder(node) or facts.is_method_stub(node):
390
514
  continue
391
515
  hits.append((node, label))
392
516
  return hits
@@ -66,7 +66,7 @@ comments — `# type:`, `# noqa`, `# sarj-noqa`,
66
66
  coding declarations.
67
67
 
68
68
  Also NOT flagged (famous-repo sweep hardening):
69
- - generated files (`_paths.is_generated_source`) — their banners are the
69
+ - generated files (`_paths.is_generated`) — their banners are the
70
70
  generator's warning header, not hand-written cruft;
71
71
  - a punctuation-only "banner" directly beneath a texty comment line — that is
72
72
  an RST-style heading underline or an ASCII-diagram row inside a prose comment
@@ -153,7 +153,7 @@ from sarj_python_lint.rules._comments import (
153
153
  nested_comment_lines,
154
154
  standalone_comments,
155
155
  )
156
- from sarj_python_lint.rules._paths import is_generated_source
156
+ from sarj_python_lint.rules._paths import is_generated
157
157
 
158
158
 
159
159
  if TYPE_CHECKING:
@@ -683,7 +683,7 @@ class NoCommentCruft(Rule):
683
683
 
684
684
  @override
685
685
  def check(self, path: Path, source: str) -> list[Diagnostic]:
686
- if is_generated_source(source):
686
+ if is_generated(path, source):
687
687
  return []
688
688
  # A Sphinx `docs/**/conf.py` is quickstart-generated boilerplate whose
689
689
  # `# -- Section ----` banners are the tool's own convention.
@@ -69,7 +69,7 @@ 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
72
+ * **generated files** (`_paths.is_generated`). Their layout is the
73
73
  generator's, and re-running the generator discards any edit, so a finding
74
74
  there can never be acted on in place. Measured on the 69 `DO NOT EDIT`
75
75
  files git-tracked across bulbul and noura-be — Speakeasy's
@@ -86,7 +86,7 @@ from typing import TYPE_CHECKING, override
86
86
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
87
87
  from sarj_python_lint.rules._ast_index import children, nodes
88
88
  from sarj_python_lint.rules._logging import is_logger_expr
89
- from sarj_python_lint.rules._paths import is_generated_source
89
+ from sarj_python_lint.rules._paths import is_generated
90
90
 
91
91
 
92
92
  if TYPE_CHECKING:
@@ -330,7 +330,7 @@ class NoFatTryBlocks(Rule):
330
330
 
331
331
  @override
332
332
  def check(self, path: Path, source: str) -> list[Diagnostic]:
333
- if is_generated_source(source):
333
+ if is_generated(path, source):
334
334
  return []
335
335
  tree = parse_or_none(path, source)
336
336
  if tree is None:
@@ -56,7 +56,7 @@ from typing import TYPE_CHECKING, final, override
56
56
 
57
57
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
58
58
  from sarj_python_lint.rules._ast_index import nodes
59
- from sarj_python_lint.rules._paths import is_generated_source
59
+ from sarj_python_lint.rules._paths import is_generated
60
60
  from sarj_python_lint.rules._sql import strip_sql_noise
61
61
 
62
62
 
@@ -111,7 +111,7 @@ class NoGenRandomUuidInSql(Rule):
111
111
  The diagnostics, sorted by (line, col).
112
112
 
113
113
  """
114
- if is_generated_source(source):
114
+ if is_generated(path, source):
115
115
  return []
116
116
  tree = parse_or_none(path, source)
117
117
  if tree is None:
@@ -84,7 +84,7 @@ duplicate can be suppressed per-line with `# sarj-noqa: SARJ024 — <reason>`.
84
84
 
85
85
  Skipped entirely: `conftest.py`, test files (`test_*.py` or under a `tests/`
86
86
  directory) — fixtures legitimately repeat literal payloads.
87
- * **generated files** (`_paths.is_generated_source`). Their layout is the
87
+ * **generated files** (`_paths.is_generated`). Their layout is the
88
88
  generator's, and re-running the generator discards any edit, so a finding
89
89
  there can never be acted on in place. Measured on the 69 `DO NOT EDIT`
90
90
  files git-tracked across bulbul and noura-be — Speakeasy's
@@ -100,7 +100,7 @@ from typing import TYPE_CHECKING, override
100
100
 
101
101
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
102
102
  from sarj_python_lint.rules._ast_index import children, walk
103
- from sarj_python_lint.rules._paths import is_generated_source
103
+ from sarj_python_lint.rules._paths import is_generated
104
104
 
105
105
 
106
106
  if TYPE_CHECKING:
@@ -130,7 +130,7 @@ class NoRepeatedStringLiteral(Rule):
130
130
 
131
131
  @override
132
132
  def check(self, path: Path, source: str) -> list[Diagnostic]:
133
- if is_generated_source(source):
133
+ if is_generated(path, source):
134
134
  return []
135
135
  if _is_skipped_path(path):
136
136
  return []
@@ -105,7 +105,7 @@ from sarj_python_lint.rules._comments import (
105
105
  restates,
106
106
  standalone_comments,
107
107
  )
108
- from sarj_python_lint.rules._paths import is_generated_source
108
+ from sarj_python_lint.rules._paths import is_generated
109
109
 
110
110
 
111
111
  if TYPE_CHECKING:
@@ -299,7 +299,7 @@ class NoRestatedComment(Rule):
299
299
 
300
300
  @override
301
301
  def check(self, path: Path, source: str) -> list[Diagnostic]:
302
- if is_generated_source(source):
302
+ if is_generated(path, source):
303
303
  return []
304
304
  try:
305
305
  standalone, _ = standalone_comments(source)
@@ -62,7 +62,7 @@ from typing import TYPE_CHECKING, final, override
62
62
 
63
63
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
64
64
  from sarj_python_lint.rules._ast_index import nodes, walk
65
- from sarj_python_lint.rules._paths import is_generated_source, is_test_path
65
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
66
66
 
67
67
 
68
68
  if TYPE_CHECKING:
@@ -113,7 +113,7 @@ class NoStdlibLogging(Rule):
113
113
  The diagnostics, sorted by (line, col).
114
114
 
115
115
  """
116
- if is_test_path(path) or _EXEMPT_DIR_NAMES.intersection(path.parts) or is_generated_source(source):
116
+ if is_test_path(path) or _EXEMPT_DIR_NAMES.intersection(path.parts) or is_generated(path, source):
117
117
  return []
118
118
  # Every diagnostic comes from an `import logging...` statement, which
119
119
  # cannot exist unless the module name is spelled in the text. Checking
@@ -206,7 +206,7 @@ Deliberately NOT flagged:
206
206
  f-string" would restyle an injection instead of fixing it, and this rule must
207
207
  not become a competing SQL rule. The keyword match is case-SENSITIVE, so
208
208
  prose like `"copied from " + src` is not mistaken for a query. 7 hits,
209
- * **generated files** (`_paths.is_generated_source`). Re-running the generator
209
+ * **generated files** (`_paths.is_generated`). Re-running the generator
210
210
  discards any edit, so a style finding there can never be acted on — the same
211
211
  exemption SARJ002 makes. 5 hits, all in bulbul's Speakeasy
212
212
  `python/sdk/src/sarj_platform_sdk/`.
@@ -229,7 +229,7 @@ from typing import TYPE_CHECKING, override
229
229
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
230
230
  from sarj_python_lint.rules._ast_index import nodes, walk
231
231
  from sarj_python_lint.rules._logging import is_logger_expr
232
- from sarj_python_lint.rules._paths import is_generated_source
232
+ from sarj_python_lint.rules._paths import is_generated
233
233
 
234
234
 
235
235
  if TYPE_CHECKING:
@@ -326,7 +326,7 @@ class PreferFstringOverConcat(Rule):
326
326
  One diagnostic per outermost offending concatenation, sorted by position.
327
327
 
328
328
  """
329
- if "+" not in source or is_generated_source(source):
329
+ if "+" not in source or is_generated(path, source):
330
330
  return []
331
331
  tree = parse_or_none(path, source)
332
332
  if tree is None:
@@ -27,6 +27,18 @@ Example refactoring:
27
27
  case dict():
28
28
  return parse_dict(data)
29
29
  return cast(..., data)
30
+
31
+ ## Not reported
32
+
33
+ * **generated files** (`_paths.is_generated`). This is the exemption that
34
+ matters most for this rule: the try/raise idiom in the module summary above
35
+ is *transcribed from* openapi-python-client's `_parse_*` template, and it
36
+ reproduces once per nullable field. Before the path half of `is_generated`
37
+ existed, 314 of this rule's 334 findings over two first-party corpora came
38
+ from a single checked-in SDK — a tree the consuming repo already excludes
39
+ from ruff, from its pre-commit hook and from its CI invocation. The
40
+ generator's output is not a refactor anyone can accept; re-running the
41
+ generator would undo it. The 20 findings that remain are hand-written.
30
42
  """
31
43
 
32
44
  from __future__ import annotations
@@ -35,7 +47,7 @@ import ast
35
47
  from typing import TYPE_CHECKING, final, override
36
48
 
37
49
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
38
- from sarj_python_lint.rules._paths import is_generated_source
50
+ from sarj_python_lint.rules._paths import is_generated
39
51
 
40
52
 
41
53
  if TYPE_CHECKING:
@@ -263,7 +275,7 @@ class PreferMatchTypeDispatch(Rule):
263
275
 
264
276
  @override
265
277
  def check(self, path: Path, source: str) -> list[Diagnostic]:
266
- if is_generated_source(source):
278
+ if is_generated(path, source):
267
279
  return []
268
280
  tree = parse_or_none(path, source)
269
281
  if tree is None:
@@ -69,7 +69,7 @@ Deliberately NOT flagged:
69
69
  so they are reads, while `x.sort()` mutates in place and bails.
70
70
  * **Tiny displays** (`< _MIN_ELEMENTS` entries) read better next to their use.
71
71
  * **Test files and generated files** (`_paths.is_test_path` /
72
- `_paths.is_generated_source`): fixture tables belong next to the assertion
72
+ `_paths.is_generated`): fixture tables belong next to the assertion
73
73
  that explains them, and generated code mirrors its generator.
74
74
  * **Bindings the function never reads** and **functions that call `locals()` /
75
75
  `vars()`**. Both are the same hole in the escape analysis: a local can leave
@@ -104,7 +104,7 @@ from typing import TYPE_CHECKING, override
104
104
 
105
105
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
106
106
  from sarj_python_lint.rules._ast_index import children, nodes
107
- from sarj_python_lint.rules._paths import is_generated_source, is_test_path
107
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
108
108
 
109
109
 
110
110
  if TYPE_CHECKING:
@@ -194,7 +194,7 @@ class PreferModuleLevelConstant(Rule):
194
194
 
195
195
  @override
196
196
  def check(self, path: Path, source: str) -> list[Diagnostic]:
197
- if is_test_path(path) or is_generated_source(source):
197
+ if is_test_path(path) or is_generated(path, source):
198
198
  return []
199
199
  tree = parse_or_none(path, source)
200
200
  if tree is None:
@@ -70,7 +70,7 @@ 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
73
+ * **generated files** (`_paths.is_generated`). Their layout is the
74
74
  generator's, and re-running the generator discards any edit, so a finding
75
75
  there can never be acted on in place. Measured on the 69 `DO NOT EDIT`
76
76
  files git-tracked across bulbul and noura-be — Speakeasy's
@@ -87,7 +87,7 @@ from typing import TYPE_CHECKING, override
87
87
 
88
88
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
89
89
  from sarj_python_lint.rules._ast_index import children, nodes, walk
90
- from sarj_python_lint.rules._paths import is_generated_source, is_test_path
90
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
91
91
 
92
92
 
93
93
  if TYPE_CHECKING:
@@ -147,7 +147,7 @@ class PreferNamedtupleOverTupleReturn(Rule):
147
147
 
148
148
  @override
149
149
  def check(self, path: Path, source: str) -> list[Diagnostic]:
150
- if is_generated_source(source):
150
+ if is_generated(path, source):
151
151
  return []
152
152
  if is_test_path(path):
153
153
  return []
@@ -40,7 +40,7 @@ from typing import TYPE_CHECKING, override
40
40
 
41
41
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
42
42
  from sarj_python_lint.rules._ast_index import walk
43
- from sarj_python_lint.rules._paths import is_generated_source, is_test_path
43
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
44
44
 
45
45
 
46
46
  if TYPE_CHECKING:
@@ -61,7 +61,7 @@ class PreferNonNullableCollection(Rule):
61
61
 
62
62
  @override
63
63
  def check(self, path: Path, source: str) -> list[Diagnostic]:
64
- if is_test_path(path) or _is_generated_or_vendored_path(path) or is_generated_source(source):
64
+ if is_test_path(path) or is_generated(path, source):
65
65
  return []
66
66
  tree = parse_or_none(path, source)
67
67
  if tree is None:
@@ -94,10 +94,6 @@ class PreferNonNullableCollection(Rule):
94
94
  return diags
95
95
 
96
96
 
97
- def _is_generated_or_vendored_path(path: Path) -> bool:
98
- return any(part.lower() in {"generated", "vendor", "vendored"} for part in path.parts)
99
-
100
-
101
97
  def _qualified_name(node: ast.expr) -> str:
102
98
  if isinstance(node, ast.Name):
103
99
  return node.id
@@ -71,7 +71,7 @@ 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
74
+ * **generated files** (`_paths.is_generated`). Their layout is the
75
75
  generator's, and re-running the generator discards any edit, so a finding
76
76
  there can never be acted on in place. Measured on the 69 `DO NOT EDIT`
77
77
  files git-tracked across bulbul and noura-be — Speakeasy's
@@ -87,7 +87,7 @@ from typing import TYPE_CHECKING, override
87
87
 
88
88
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
89
89
  from sarj_python_lint.rules._ast_index import nodes
90
- from sarj_python_lint.rules._paths import is_generated_source, is_test_path
90
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
91
91
 
92
92
 
93
93
  if TYPE_CHECKING:
@@ -164,7 +164,7 @@ class PreferTimedeltaForDurations(Rule):
164
164
 
165
165
  @override
166
166
  def check(self, path: Path, source: str) -> list[Diagnostic]:
167
- if is_generated_source(source):
167
+ if is_generated(path, source):
168
168
  return []
169
169
  if is_test_path(path):
170
170
  return []
@@ -55,7 +55,7 @@ from typing import TYPE_CHECKING, override
55
55
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
56
56
  from sarj_python_lint.rules._ast_index import children
57
57
  from sarj_python_lint.rules._comments import is_protected, split_identifier, stem
58
- from sarj_python_lint.rules._paths import is_generated_source
58
+ from sarj_python_lint.rules._paths import is_generated
59
59
 
60
60
 
61
61
  if TYPE_CHECKING:
@@ -239,7 +239,7 @@ class RedundantDocstring(Rule):
239
239
 
240
240
  @override
241
241
  def check(self, path: Path, source: str) -> list[Diagnostic]:
242
- if is_generated_source(source):
242
+ if is_generated(path, source):
243
243
  return []
244
244
  tree = parse_or_none(path, source)
245
245
  if tree is None:
@@ -266,7 +266,7 @@ import re
266
266
  from typing import TYPE_CHECKING, override
267
267
 
268
268
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
269
- from sarj_python_lint.rules._paths import is_generated_source, is_test_path
269
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
270
270
 
271
271
 
272
272
  if TYPE_CHECKING:
@@ -429,7 +429,7 @@ class RequirePortForService(Rule):
429
429
  One diagnostic per unsubstitutable service class, sorted by position.
430
430
 
431
431
  """
432
- if not _is_library_source(path) or is_generated_source(source):
432
+ if not _is_library_source(path) or is_generated(path, source):
433
433
  return []
434
434
  tree = parse_or_none(path, source)
435
435
  if tree is None:
@@ -39,7 +39,7 @@ 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
42
+ * **generated files** (`_paths.is_generated`). Their layout is the
43
43
  generator's, and re-running the generator discards any edit, so a finding
44
44
  there can never be acted on in place. Measured on the 69 `DO NOT EDIT`
45
45
  files git-tracked across bulbul and noura-be — Speakeasy's
@@ -53,7 +53,7 @@ import re
53
53
  from typing import TYPE_CHECKING, override
54
54
 
55
55
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
56
- from sarj_python_lint.rules._paths import is_generated_source
56
+ from sarj_python_lint.rules._paths import is_generated
57
57
 
58
58
 
59
59
  if TYPE_CHECKING:
@@ -134,7 +134,7 @@ class SinglePublicExport(Rule):
134
134
 
135
135
  @override
136
136
  def check(self, path: Path, source: str) -> list[Diagnostic]:
137
- if is_generated_source(source):
137
+ if is_generated(path, source):
138
138
  return []
139
139
  if _is_skipped_path(path):
140
140
  return []
@@ -58,7 +58,7 @@ 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
61
+ * **generated files** (`_paths.is_generated`). Their layout is the
62
62
  generator's, and re-running the generator discards any edit, so a finding
63
63
  there can never be acted on in place. Measured on the 69 `DO NOT EDIT`
64
64
  files git-tracked across bulbul and noura-be — Speakeasy's
@@ -73,7 +73,7 @@ from typing import TYPE_CHECKING, override
73
73
 
74
74
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
75
75
  from sarj_python_lint.rules._ast_index import children
76
- from sarj_python_lint.rules._paths import is_generated_source
76
+ from sarj_python_lint.rules._paths import is_generated
77
77
 
78
78
 
79
79
  if TYPE_CHECKING:
@@ -112,7 +112,7 @@ class Stepdown(Rule):
112
112
 
113
113
  @override
114
114
  def check(self, path: Path, source: str) -> list[Diagnostic]:
115
- if is_generated_source(source):
115
+ if is_generated(path, source):
116
116
  return []
117
117
  if _is_test_path(path):
118
118
  return []
@@ -69,7 +69,7 @@ from sarj_python_lint.rules._comments import (
69
69
  stem,
70
70
  trailing_comments,
71
71
  )
72
- from sarj_python_lint.rules._paths import is_generated_source
72
+ from sarj_python_lint.rules._paths import is_generated
73
73
 
74
74
 
75
75
  if TYPE_CHECKING:
@@ -191,7 +191,7 @@ class TrailingValueNarration(Rule):
191
191
 
192
192
  @override
193
193
  def check(self, path: Path, source: str) -> list[Diagnostic]:
194
- if is_generated_source(source):
194
+ if is_generated(path, source):
195
195
  return []
196
196
  try:
197
197
  trailing = trailing_comments(source)
@@ -1,63 +0,0 @@
1
- """Shared file-scope predicates for the file-scope-gated rules.
2
-
3
- A single definition of "is this a test file?" so the test-scoped rules
4
- (SARJ031 no-sleep-in-test-body, SARJ036 no-raw-sql-in-tests) and the rules
5
- that *exempt* tests (SARJ014 prefer-timedelta-for-durations, SARJ015
6
- prefer-struct-over-namedtuple, SARJ026 prefer-namedtuple-over-tuple-return,
7
- SARJ034 kwonly-same-type-params) never diverge on what counts as a test file.
8
-
9
- Also the single definition of "is this a generated file?" (SARJ016/034):
10
- generated code mirrors whatever its generator emits, so style rules cannot be
11
- acted on there.
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- import re
17
- from typing import TYPE_CHECKING
18
-
19
-
20
- if TYPE_CHECKING:
21
- from pathlib import Path
22
-
23
-
24
- _TEST_DIR_NAMES = frozenset({"tests", "test"})
25
-
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
- )
32
-
33
- _GENERATED_HEADER_LINES = 5
34
-
35
-
36
- def is_generated_source(source: str) -> bool:
37
- """Report whether `source` self-identifies as generated code.
38
-
39
- Checks the first few lines for the conventional markers
40
- ("autogenerated", "generated by", "do not edit").
41
-
42
- Returns:
43
- True when the file header carries a generated-code marker.
44
-
45
- """
46
- head = source.splitlines()[:_GENERATED_HEADER_LINES]
47
- return any(_GENERATED_RE.search(line) for line in head)
48
-
49
-
50
- def is_test_path(path: Path) -> bool:
51
- """Report whether `path` is a test file.
52
-
53
- A test file is `conftest.py`, a `test_*.py` / `*_test.py` stem, or any file
54
- under a `tests` / `test` directory segment.
55
-
56
- Returns:
57
- True when `path` belongs to the test tree.
58
-
59
- """
60
- name = path.name
61
- if name == "conftest.py" or name.startswith("test_") or name.endswith("_test.py"):
62
- return True
63
- return any(part in _TEST_DIR_NAMES for part in path.parts)