strict-module 0.5.0__py3-none-any.whl
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.
- strict_module/__init__.py +7 -0
- strict_module/checkers.py +806 -0
- strict_module/cli.py +152 -0
- strict_module/config/__init__.py +6 -0
- strict_module/config/_config.py +97 -0
- strict_module/config/_version.py +3 -0
- strict_module/linter.py +212 -0
- strict_module/loc_cap.py +228 -0
- strict_module/py.typed +0 -0
- strict_module/rules.py +362 -0
- strict_module-0.5.0.dist-info/METADATA +740 -0
- strict_module-0.5.0.dist-info/RECORD +15 -0
- strict_module-0.5.0.dist-info/WHEEL +4 -0
- strict_module-0.5.0.dist-info/entry_points.txt +3 -0
- strict_module-0.5.0.dist-info/licenses/LICENSE +201 -0
strict_module/rules.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Rule definitions for strict-module linter (formerly dto-strict)."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import fnmatch
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RuleSeverity(Enum):
|
|
12
|
+
"""Severity levels for violations."""
|
|
13
|
+
|
|
14
|
+
HIGH = "HIGH"
|
|
15
|
+
MEDIUM = "MEDIUM"
|
|
16
|
+
LOW = "LOW"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class Violation:
|
|
21
|
+
"""Represents a single linting violation."""
|
|
22
|
+
|
|
23
|
+
rule_id: str
|
|
24
|
+
severity: RuleSeverity
|
|
25
|
+
file: str
|
|
26
|
+
line: int
|
|
27
|
+
col: int
|
|
28
|
+
message: str
|
|
29
|
+
|
|
30
|
+
def format_text(self) -> str:
|
|
31
|
+
"""Format as plain text."""
|
|
32
|
+
return f"{self.file}:{self.line}: {self.rule_id} {self.message}"
|
|
33
|
+
|
|
34
|
+
def format_github(self) -> str:
|
|
35
|
+
"""Format as GitHub Actions annotation."""
|
|
36
|
+
level = "error" if self.severity == RuleSeverity.HIGH else "warning"
|
|
37
|
+
return f"::{level} file={self.file},line={self.line},col={self.col}::{self.message}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class Rule:
|
|
42
|
+
"""Rule definition."""
|
|
43
|
+
|
|
44
|
+
id: str
|
|
45
|
+
name: str
|
|
46
|
+
severity: RuleSeverity
|
|
47
|
+
description: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RuleRegistry:
|
|
51
|
+
"""Registry of all linting rules."""
|
|
52
|
+
|
|
53
|
+
RULES = {
|
|
54
|
+
"R001": Rule(
|
|
55
|
+
"R001",
|
|
56
|
+
"Dict[str, Any] or bare collections in service signatures",
|
|
57
|
+
RuleSeverity.HIGH,
|
|
58
|
+
"Function signature contains Dict[str, Any], bare dict, list, or tuple without type parameters.",
|
|
59
|
+
),
|
|
60
|
+
"R002": Rule(
|
|
61
|
+
"R002",
|
|
62
|
+
"Inline dict literals with 3+ keys",
|
|
63
|
+
RuleSeverity.MEDIUM,
|
|
64
|
+
"Inline dict literal with multiple string keys; consider converting to DTO.",
|
|
65
|
+
),
|
|
66
|
+
"R003": Rule(
|
|
67
|
+
"R003",
|
|
68
|
+
"Dataclass uses anti-canonical repr=False",
|
|
69
|
+
RuleSeverity.MEDIUM,
|
|
70
|
+
"Dataclass uses anti-canonical repr=False. Per strict-module canonical (v0.2): plain @dataclass(frozen=True, slots=True). Remove repr=False.",
|
|
71
|
+
),
|
|
72
|
+
"R004": Rule(
|
|
73
|
+
"R004",
|
|
74
|
+
"Module-level function without exception tag",
|
|
75
|
+
RuleSeverity.HIGH,
|
|
76
|
+
"Module-level function definition lacks documented exception tag (facade pattern).",
|
|
77
|
+
),
|
|
78
|
+
"R005": Rule(
|
|
79
|
+
"R005",
|
|
80
|
+
"Validator not using DTO.from_dict() pattern",
|
|
81
|
+
RuleSeverity.LOW,
|
|
82
|
+
"Validator function should use DTO.from_dict() to validate payload shape.",
|
|
83
|
+
),
|
|
84
|
+
"R006": Rule(
|
|
85
|
+
"R006",
|
|
86
|
+
"typing.Any in signature",
|
|
87
|
+
RuleSeverity.HIGH,
|
|
88
|
+
"Function signature contains typing.Any in parameter or return type. Use forward refs (TYPE_CHECKING) for class types, narrow protocols (IO[bytes]) for file objects, or build a proper DTO for business shapes.",
|
|
89
|
+
),
|
|
90
|
+
"R007": Rule(
|
|
91
|
+
"R007",
|
|
92
|
+
"Pytest fixtures defined outside conftest.py",
|
|
93
|
+
RuleSeverity.MEDIUM,
|
|
94
|
+
"Pytest fixture @pytest.fixture decorator found in non-conftest file. Fixtures must be defined in conftest.py.",
|
|
95
|
+
),
|
|
96
|
+
"R008": Rule(
|
|
97
|
+
"R008",
|
|
98
|
+
"Bare module-level test function",
|
|
99
|
+
RuleSeverity.MEDIUM,
|
|
100
|
+
"Module-level test function def test_*() found. Test functions must be defined as methods in Test<Concern> classes.",
|
|
101
|
+
),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def get_rule(cls, rule_id: str) -> Optional[Rule]:
|
|
106
|
+
"""Get rule by ID."""
|
|
107
|
+
return cls.RULES.get(rule_id)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def is_test_file(file_path: Path) -> bool:
|
|
111
|
+
"""Check if file is a test file (for linting rule exemptions).
|
|
112
|
+
|
|
113
|
+
A file is a test file if:
|
|
114
|
+
- Its basename is conftest.py, OR
|
|
115
|
+
- Its basename matches test_*.py
|
|
116
|
+
|
|
117
|
+
Note: We do NOT check for /tests/ in the path for linting purposes,
|
|
118
|
+
as test fixture files (like tests/fixtures/bad/*.py used by the linter
|
|
119
|
+
itself) should still be linted. The loc-cap tool has its own exemption
|
|
120
|
+
logic (is_loc_test_file) for excluding all files under tests/.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
file_path: Path to the file
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
True if file is a test file, False otherwise
|
|
127
|
+
"""
|
|
128
|
+
basename = file_path.name
|
|
129
|
+
|
|
130
|
+
# Check basename patterns
|
|
131
|
+
if basename == "conftest.py" or basename.startswith("test_"):
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def is_loc_test_file(file_path: Path) -> bool:
|
|
138
|
+
"""Check if file is a test file (for loc-cap exemptions).
|
|
139
|
+
|
|
140
|
+
A file is a test file if:
|
|
141
|
+
- Its basename is conftest.py, OR
|
|
142
|
+
- Its basename matches test_*.py, OR
|
|
143
|
+
- It is under a tests/ directory
|
|
144
|
+
|
|
145
|
+
Used by loc-cap to exempt test files from LOC enforcement.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
file_path: Path to the file
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
True if file is a test file, False otherwise
|
|
152
|
+
"""
|
|
153
|
+
basename = file_path.name
|
|
154
|
+
|
|
155
|
+
# Check basename patterns
|
|
156
|
+
if basename == "conftest.py" or basename.startswith("test_"):
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
# Check if under a tests/ directory
|
|
160
|
+
if "/tests/" in str(file_path) or str(file_path).startswith("tests/"):
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def is_service_path(file_path: Path, service_paths: list[str]) -> bool:
|
|
167
|
+
"""Check if file path matches any service path pattern.
|
|
168
|
+
|
|
169
|
+
Test files are always exempt and return False.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
file_path: Path to the file
|
|
173
|
+
service_paths: List of glob patterns to match
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
True if file matches a service path pattern (and is not a test file), False otherwise
|
|
177
|
+
"""
|
|
178
|
+
# Test files are always exempt
|
|
179
|
+
if is_test_file(file_path):
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
for pattern in service_paths:
|
|
183
|
+
if fnmatch.fnmatch(str(file_path), pattern):
|
|
184
|
+
return True
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def is_dto_path(file_path: Path, dto_paths: list[str]) -> bool:
|
|
189
|
+
"""Check if file path matches any DTO path pattern.
|
|
190
|
+
|
|
191
|
+
Test files are always exempt and return False.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
file_path: Path to the file
|
|
195
|
+
dto_paths: List of glob patterns to match
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
True if file matches a DTO path pattern (and is not a test file), False otherwise
|
|
199
|
+
"""
|
|
200
|
+
# Test files are always exempt
|
|
201
|
+
if is_test_file(file_path):
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
for pattern in dto_paths:
|
|
205
|
+
if fnmatch.fnmatch(str(file_path), pattern):
|
|
206
|
+
return True
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def has_noqa_comment(node: ast.AST, rule_id: str, source_lines: list[str]) -> bool:
|
|
211
|
+
"""Check if node has a noqa comment for the given rule.
|
|
212
|
+
|
|
213
|
+
Recognizes:
|
|
214
|
+
- `# noqa` (suppresses all rules)
|
|
215
|
+
- `# noqa: strict-module` (suppresses all strict-module rules)
|
|
216
|
+
- `# noqa: strict-module-R001` (suppresses specific rule)
|
|
217
|
+
- `# noqa: dto-strict` (backward-compat, suppresses all rules)
|
|
218
|
+
- `# noqa: dto-strict-R001` (backward-compat, suppresses specific rule)
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
node: AST node to check
|
|
222
|
+
rule_id: Rule ID to check (e.g., "R001")
|
|
223
|
+
source_lines: Source file split into lines (0-indexed)
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
True if node's line has a matching noqa comment, False otherwise
|
|
227
|
+
"""
|
|
228
|
+
if not hasattr(node, "lineno") or node.lineno is None:
|
|
229
|
+
return False
|
|
230
|
+
if node.lineno > len(source_lines):
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
line = source_lines[node.lineno - 1]
|
|
234
|
+
|
|
235
|
+
# Check if line contains a noqa comment
|
|
236
|
+
if "#" not in line:
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
# Find the comment part (after #)
|
|
240
|
+
comment_idx = line.find("#")
|
|
241
|
+
comment_part = line[comment_idx + 1 :].strip()
|
|
242
|
+
|
|
243
|
+
# Check if the comment contains "noqa"
|
|
244
|
+
if not comment_part.startswith("noqa"):
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
noqa_part = comment_part[4:].strip() # Skip "noqa" (4 chars)
|
|
248
|
+
|
|
249
|
+
# Bare `# noqa` suppresses all rules
|
|
250
|
+
if not noqa_part or noqa_part.startswith("#") or noqa_part.startswith("-"):
|
|
251
|
+
return True
|
|
252
|
+
|
|
253
|
+
# Check for colon-based specs
|
|
254
|
+
if noqa_part.startswith(":"):
|
|
255
|
+
spec = noqa_part[1:].strip()
|
|
256
|
+
# Remove any trailing comment (# indicates start of new comment)
|
|
257
|
+
if "#" in spec:
|
|
258
|
+
spec = spec.split("#")[0].strip()
|
|
259
|
+
|
|
260
|
+
# Remove any trailing dash-delimited explanation (e.g., "- explanation")
|
|
261
|
+
parts = spec.split()
|
|
262
|
+
if len(parts) > 1:
|
|
263
|
+
# Check if second part starts with any dash character (-, –, —, etc.)
|
|
264
|
+
second = parts[1]
|
|
265
|
+
if second and second[0] in "-–—":
|
|
266
|
+
# Trailing dash indicates explanation after the spec
|
|
267
|
+
spec = parts[0]
|
|
268
|
+
|
|
269
|
+
# `# noqa: strict-module` suppresses all strict-module rules
|
|
270
|
+
if spec == "strict-module":
|
|
271
|
+
return True
|
|
272
|
+
|
|
273
|
+
# `# noqa: strict-module-R001` suppresses R001 specifically
|
|
274
|
+
if spec == f"strict-module-{rule_id}":
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
# `# noqa: dto-strict` suppresses all rules (backward-compat)
|
|
278
|
+
if spec == "dto-strict":
|
|
279
|
+
return True
|
|
280
|
+
|
|
281
|
+
# `# noqa: dto-strict-R001` suppresses R001 (backward-compat)
|
|
282
|
+
if spec == f"dto-strict-{rule_id}":
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
# Handle comma-separated list (both new and old names)
|
|
286
|
+
tokens = [t.strip() for t in spec.split(",")]
|
|
287
|
+
if f"strict-module-{rule_id}" in tokens or f"dto-strict-{rule_id}" in tokens:
|
|
288
|
+
return True
|
|
289
|
+
|
|
290
|
+
return False
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def get_annotation_string(annotation: Optional[ast.expr]) -> str:
|
|
294
|
+
"""Convert annotation AST node to string representation."""
|
|
295
|
+
if annotation is None:
|
|
296
|
+
return ""
|
|
297
|
+
|
|
298
|
+
if isinstance(annotation, ast.Name):
|
|
299
|
+
return annotation.id
|
|
300
|
+
elif isinstance(annotation, ast.Subscript):
|
|
301
|
+
base = get_annotation_string(annotation.value)
|
|
302
|
+
if isinstance(annotation.slice, ast.Index):
|
|
303
|
+
# Python 3.8 compatibility
|
|
304
|
+
index = get_annotation_string(annotation.slice.value)
|
|
305
|
+
else:
|
|
306
|
+
index = get_annotation_string(annotation.slice)
|
|
307
|
+
return f"{base}[{index}]"
|
|
308
|
+
elif isinstance(annotation, ast.Tuple):
|
|
309
|
+
elements = ", ".join(get_annotation_string(e) for e in annotation.elts)
|
|
310
|
+
return f"({elements})"
|
|
311
|
+
elif isinstance(annotation, ast.Attribute):
|
|
312
|
+
value = get_annotation_string(annotation.value)
|
|
313
|
+
return f"{value}.{annotation.attr}"
|
|
314
|
+
elif isinstance(annotation, ast.Constant):
|
|
315
|
+
return repr(annotation.value)
|
|
316
|
+
else:
|
|
317
|
+
return ""
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def is_dict_str_any(annotation: Optional[ast.expr]) -> bool:
|
|
321
|
+
"""Check if annotation is Dict[str, Any] or dict[str, Any]."""
|
|
322
|
+
if annotation is None:
|
|
323
|
+
return False
|
|
324
|
+
|
|
325
|
+
# Use ast.unparse for more reliable type detection
|
|
326
|
+
try:
|
|
327
|
+
ann_str = ast.unparse(annotation)
|
|
328
|
+
except Exception:
|
|
329
|
+
return False
|
|
330
|
+
|
|
331
|
+
ann_lower = ann_str.lower().replace(" ", "")
|
|
332
|
+
|
|
333
|
+
# Check for typing.Dict[str, Any], Dict[str, Any], dict[str, Any]
|
|
334
|
+
return "dict[str,any]" in ann_lower
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def is_bare_collection(annotation: Optional[ast.expr]) -> bool:
|
|
338
|
+
"""Check if annotation is bare dict, list, or tuple without type parameters."""
|
|
339
|
+
if annotation is None:
|
|
340
|
+
return False
|
|
341
|
+
|
|
342
|
+
# Only Name nodes without subscripts are bare collections
|
|
343
|
+
if isinstance(annotation, ast.Name):
|
|
344
|
+
return annotation.id in ("dict", "list", "tuple")
|
|
345
|
+
|
|
346
|
+
return False
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def contains_any(annotation: Optional[ast.expr]) -> bool:
|
|
350
|
+
"""Check if annotation contains typing.Any."""
|
|
351
|
+
if annotation is None:
|
|
352
|
+
return False
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
ann_str = ast.unparse(annotation)
|
|
356
|
+
except Exception:
|
|
357
|
+
return False
|
|
358
|
+
|
|
359
|
+
ann_lower = ann_str.lower().replace(" ", "")
|
|
360
|
+
|
|
361
|
+
# Check for Any in various forms: Any, Optional[Any], list[Any], dict[str, Any], etc.
|
|
362
|
+
return "any" in ann_lower
|