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/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