gabriel / muse public
test_typing_audit.py python
757 lines 29.2 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Tests for tools/typing_audit.py — the zero-tolerance typing enforcer.
2
3 Covers every check the audit makes, the self-auditing invariant (the script
4 passes its own rules), string-literal masking, CLI flags, and the ratchet.
5
6 All tests are pure-unit — no file-system access beyond tmp_path fixtures,
7 no network, no subprocess (we import the module directly).
8 """
9
10 from __future__ import annotations
11
12 import json
13 import sys
14 from pathlib import Path
15
16 import pytest
17
18 # ── Import the tool under test ──────────────────────────────────────────────
19 # tools/ is not on sys.path by default; add it so we can import directly.
20 _TOOLS_DIR = Path(__file__).parent.parent / "tools"
21 if str(_TOOLS_DIR) not in sys.path:
22 sys.path.insert(0, str(_TOOLS_DIR))
23
24 from typing_audit import ( # noqa: E402
25 FileResult,
26 Offender,
27 Report,
28 ReportSummary,
29 UntypedDef,
30 _classify_type_ignore,
31 _find_untyped_defs,
32 _imports_any,
33 _mask_string_literals,
34 generate_report,
35 print_human_summary,
36 scan_file,
37 scan_directory,
38 )
39
40 type _PatternsMap = dict[str, int]
41
42 # ---------------------------------------------------------------------------
43 # Helpers
44 # ---------------------------------------------------------------------------
45
46
47 def _write(tmp_path: Path, name: str, content: str) -> Path:
48 """Write *content* to ``tmp_path/name`` and return the path."""
49 p = tmp_path / name
50 p.write_text(content, encoding="utf-8")
51 return p
52
53
54 def _scan(content: str, tmp_path: Path) -> FileResult:
55 """Write *content* to a temp file, scan it, and return the result."""
56 p = _write(tmp_path, "subject.py", content)
57 result = scan_file(p)
58 assert result is not None, "scan_file returned None for a valid file"
59 return result
60
61
62 def _total(result: FileResult) -> int:
63 """Return total violation count for *result*."""
64 return sum(result["patterns"].values())
65
66
67 # ---------------------------------------------------------------------------
68 # Self-auditing invariant
69 # ---------------------------------------------------------------------------
70
71
72 class TestSelfAudit:
73 """The audit script itself must pass its own zero-violation ratchet."""
74
75 def test_tool_has_zero_violations(self) -> None:
76 """tools/typing_audit.py passes --max-any 0 on itself."""
77 tool_path = _TOOLS_DIR / "typing_audit.py"
78 result = scan_file(tool_path)
79 assert result is not None
80 total = sum(result["patterns"].values())
81 assert total == 0, (
82 f"typing_audit.py has {total} violation(s): "
83 f"{result['patterns']}"
84 )
85
86 def test_tool_has_zero_untyped_defs(self) -> None:
87 """Every function in the tool has full type annotations."""
88 tool_path = _TOOLS_DIR / "typing_audit.py"
89 result = scan_file(tool_path)
90 assert result is not None
91 assert result["untyped_defs"] == [], (
92 f"Untyped defs found in typing_audit.py: {result['untyped_defs']}"
93 )
94
95
96 # ---------------------------------------------------------------------------
97 # String-literal masking
98 # ---------------------------------------------------------------------------
99
100
101 class TestMaskStringLiterals:
102 """_mask_string_literals removes string content without altering structure."""
103
104 def test_single_quoted_string_is_blanked(self) -> None:
105 src = 'x = "hello: Any world"'
106 masked = _mask_string_literals(src)
107 assert "Any" not in masked
108
109 def test_raw_string_is_blanked(self) -> None:
110 src = r'pat = re.compile(r":\s*Any\b")'
111 masked = _mask_string_literals(src)
112 assert "Any" not in masked
113
114 def test_newlines_are_preserved_in_multiline_string(self) -> None:
115 src = '"""line one\nline two\nline three"""\nx = 1'
116 masked = _mask_string_literals(src)
117 assert masked.count("\n") == src.count("\n")
118
119 def test_code_outside_strings_is_preserved(self) -> None:
120 src = 'x: dict[str, int] = {}'
121 masked = _mask_string_literals(src)
122 assert "dict[str, int]" in masked
123
124 def test_comment_not_masked(self) -> None:
125 """Comments are not string literals — they survive masking."""
126 src = 'x = 1 # type: ignore'
127 masked = _mask_string_literals(src)
128 assert "type: ignore" in masked
129
130 def test_docstring_content_masked(self) -> None:
131 src = '"""This function uses dict[str, Any] for convenience."""\ndef f(): ...'
132 masked = _mask_string_literals(src)
133 assert "Any" not in masked
134
135 def test_pattern_in_string_does_not_trigger_violation(
136 self, tmp_path: Path
137 ) -> None:
138 """A string literal containing 'Any' must not count as a violation."""
139 result = _scan('x = "param: Any"\n', tmp_path)
140 assert _total(result) == 0
141
142 def test_raw_regex_pattern_does_not_trigger_violation(
143 self, tmp_path: Path
144 ) -> None:
145 """Raw regex strings like r':\\s*Any\\b' must not count as violations."""
146 result = _scan('import re\npat = re.compile(r":\\s*Any\\b")\n', tmp_path)
147 assert _total(result) == 0
148
149
150 # ---------------------------------------------------------------------------
151 # _imports_any
152 # ---------------------------------------------------------------------------
153
154
155 class TestImportsAny:
156 def test_detects_direct_any_import(self) -> None:
157 assert _imports_any("from typing import Any\n")
158
159 def test_detects_any_among_other_imports(self) -> None:
160 assert _imports_any("from typing import List, Any, Dict\n")
161
162 def test_does_not_flag_non_any_import(self) -> None:
163 assert not _imports_any("from typing import Optional\n")
164
165 def test_does_not_flag_any_in_comment(self) -> None:
166 assert not _imports_any("# from typing import Any\n")
167
168
169 # ---------------------------------------------------------------------------
170 # _classify_type_ignore
171 # ---------------------------------------------------------------------------
172
173
174 class TestClassifyTypeIgnore:
175 def test_blanket_ignore(self) -> None:
176 assert _classify_type_ignore("x = 1 # type: ignore") == "type_ignore[blanket]"
177
178 def test_specific_ignore(self) -> None:
179 assert _classify_type_ignore("x = 1 # type: ignore[attr-defined]") == "type_ignore[attr-defined]"
180
181 def test_multiple_codes_returned_as_is(self) -> None:
182 label = _classify_type_ignore("x = 1 # type: ignore[union-attr, arg-type]")
183 assert "union-attr" in label
184
185
186 # ---------------------------------------------------------------------------
187 # Any-as-type patterns
188 # ---------------------------------------------------------------------------
189
190
191 class TestAnyPatterns:
192 def test_dict_str_any(self, tmp_path: Path) -> None:
193 result = _scan("def f() -> dict[str, Any]: ...\n", tmp_path)
194 assert result["patterns"].get("dict_str_any", 0) >= 1
195
196 def test_list_any(self, tmp_path: Path) -> None:
197 result = _scan("def f() -> list[Any]: ...\n", tmp_path)
198 assert result["patterns"].get("list_any", 0) >= 1
199
200 def test_return_any(self, tmp_path: Path) -> None:
201 result = _scan("def f() -> Any: ...\n", tmp_path)
202 assert result["patterns"].get("return_any", 0) >= 1
203
204 def test_param_any(self, tmp_path: Path) -> None:
205 result = _scan("def f(x: Any) -> None: ...\n", tmp_path)
206 assert result["patterns"].get("param_any", 0) >= 1
207
208 def test_mapping_any(self, tmp_path: Path) -> None:
209 result = _scan("def f(x: Mapping[str, Any]) -> None: ...\n", tmp_path)
210 assert result["patterns"].get("mapping_any", 0) >= 1
211
212 def test_tuple_any(self, tmp_path: Path) -> None:
213 result = _scan("x: tuple[int, Any] = (1, 2)\n", tmp_path)
214 assert result["patterns"].get("tuple_any", 0) >= 1
215
216 def test_any_in_docstring_not_flagged(self, tmp_path: Path) -> None:
217 """Any inside docstrings must not be counted."""
218 src = '"""Accepts dict[str, Any] for flexibility."""\ndef f() -> None: ...\n'
219 result = _scan(src, tmp_path)
220 assert result["patterns"].get("dict_str_any", 0) == 0
221
222
223 # ---------------------------------------------------------------------------
224 # object-as-type patterns
225 # ---------------------------------------------------------------------------
226
227
228 class TestObjectPatterns:
229 def test_param_object(self, tmp_path: Path) -> None:
230 result = _scan("def f(x: object) -> None: ...\n", tmp_path)
231 assert result["patterns"].get("param_object", 0) >= 1
232
233 def test_return_object(self, tmp_path: Path) -> None:
234 result = _scan("def f() -> object: ...\n", tmp_path)
235 assert result["patterns"].get("return_object", 0) >= 1
236
237 def test_collection_object(self, tmp_path: Path) -> None:
238 result = _scan("x: list[object] = []\n", tmp_path)
239 assert result["patterns"].get("collection_object", 0) >= 1
240
241
242 # ---------------------------------------------------------------------------
243 # cast() — banned
244 # ---------------------------------------------------------------------------
245
246
247 class TestCastUsage:
248 def test_cast_flagged(self, tmp_path: Path) -> None:
249 result = _scan("from typing import cast\ny = cast(int, x)\n", tmp_path)
250 assert result["patterns"].get("cast_usage", 0) >= 1
251
252
253 # ---------------------------------------------------------------------------
254 # type: ignore
255 # ---------------------------------------------------------------------------
256
257
258 class TestTypeIgnore:
259 def test_bare_type_ignore_flagged(self, tmp_path: Path) -> None:
260 result = _scan("x: int = y # type: ignore\n", tmp_path)
261 assert result["patterns"].get("type_ignore", 0) >= 1
262
263 def test_specific_type_ignore_flagged(self, tmp_path: Path) -> None:
264 result = _scan("x: int = y # type: ignore[assignment]\n", tmp_path)
265 assert result["patterns"].get("type_ignore", 0) >= 1
266
267 def test_type_ignore_in_string_not_flagged(self, tmp_path: Path) -> None:
268 """The text '# type: ignore' inside a print string is not a comment."""
269 result = _scan('print("use # type: ignore sparingly")\n', tmp_path)
270 assert result["patterns"].get("type_ignore", 0) == 0
271
272 def test_type_ignore_variant_classified(self, tmp_path: Path) -> None:
273 result = _scan("x = 1 # type: ignore\n", tmp_path)
274 assert result["type_ignore_variants"].get("type_ignore[blanket]", 0) >= 1
275
276 def test_specific_variant_classified(self, tmp_path: Path) -> None:
277 result = _scan("x = 1 # type: ignore[attr-defined]\n", tmp_path)
278 assert result["type_ignore_variants"].get("type_ignore[attr-defined]", 0) >= 1
279
280
281 # ---------------------------------------------------------------------------
282 # Bare collections
283 # ---------------------------------------------------------------------------
284
285
286 class TestBareCollections:
287 def test_bare_list_in_annotation(self, tmp_path: Path) -> None:
288 result = _scan("def f() -> list: ...\n", tmp_path)
289 assert result["patterns"].get("bare_list", 0) >= 1
290
291 def test_bare_dict_in_annotation(self, tmp_path: Path) -> None:
292 result = _scan("def f() -> dict: ...\n", tmp_path)
293 assert result["patterns"].get("bare_dict", 0) >= 1
294
295 def test_bare_set_in_annotation(self, tmp_path: Path) -> None:
296 result = _scan("x: set = set()\n", tmp_path)
297 assert result["patterns"].get("bare_set", 0) >= 1
298
299 def test_bare_tuple_in_annotation(self, tmp_path: Path) -> None:
300 result = _scan("x: tuple = (1, 2)\n", tmp_path)
301 assert result["patterns"].get("bare_tuple", 0) >= 1
302
303 def test_parameterised_list_not_flagged(self, tmp_path: Path) -> None:
304 result = _scan("def f() -> list[int]: ...\n", tmp_path)
305 assert result["patterns"].get("bare_list", 0) == 0
306
307 def test_parameterised_dict_not_flagged(self, tmp_path: Path) -> None:
308 result = _scan("x: dict[str, int] = {}\n", tmp_path)
309 assert result["patterns"].get("bare_dict", 0) == 0
310
311
312 # ---------------------------------------------------------------------------
313 # Optional / Union (legacy)
314 # ---------------------------------------------------------------------------
315
316
317 class TestOptionalUnion:
318 def test_optional_usage_flagged(self, tmp_path: Path) -> None:
319 result = _scan("def f(x: Optional[int]) -> None: ...\n", tmp_path)
320 assert result["patterns"].get("optional_usage", 0) >= 1
321
322 def test_union_usage_flagged(self, tmp_path: Path) -> None:
323 result = _scan("def f(x: Union[int, str]) -> None: ...\n", tmp_path)
324 assert result["patterns"].get("union_usage", 0) >= 1
325
326 def test_pipe_union_not_flagged(self, tmp_path: Path) -> None:
327 result = _scan("def f(x: int | str) -> None: ...\n", tmp_path)
328 assert _total(result) == 0
329
330 def test_x_or_none_not_flagged(self, tmp_path: Path) -> None:
331 result = _scan("def f(x: int | None) -> None: ...\n", tmp_path)
332 assert _total(result) == 0
333
334
335 # ---------------------------------------------------------------------------
336 # Legacy typing imports
337 # ---------------------------------------------------------------------------
338
339
340 class TestLegacyImports:
341 def test_legacy_List_flagged(self, tmp_path: Path) -> None:
342 result = _scan("from typing import List\nx: List[int] = []\n", tmp_path)
343 assert result["patterns"].get("legacy_List", 0) >= 1
344
345 def test_legacy_Dict_flagged(self, tmp_path: Path) -> None:
346 result = _scan("from typing import Dict\nx: Dict[str, int] = {}\n", tmp_path)
347 assert result["patterns"].get("legacy_Dict", 0) >= 1
348
349 def test_legacy_Set_flagged(self, tmp_path: Path) -> None:
350 result = _scan("from typing import Set\nx: Set[int] = set()\n", tmp_path)
351 assert result["patterns"].get("legacy_Set", 0) >= 1
352
353 def test_legacy_Tuple_flagged(self, tmp_path: Path) -> None:
354 result = _scan("from typing import Tuple\nx: Tuple[int, str] = (1, 'a')\n", tmp_path)
355 assert result["patterns"].get("legacy_Tuple", 0) >= 1
356
357
358 # ---------------------------------------------------------------------------
359 # Callable patterns (new in rewrite)
360 # ---------------------------------------------------------------------------
361
362
363 class TestCallablePatterns:
364 def test_bare_callable_in_annotation_flagged(self, tmp_path: Path) -> None:
365 result = _scan("def f(cb: Callable) -> None: ...\n", tmp_path)
366 assert result["patterns"].get("bare_callable", 0) >= 1
367
368 def test_bare_callable_as_return_flagged(self, tmp_path: Path) -> None:
369 result = _scan("def f() -> Callable: ...\n", tmp_path)
370 assert result["patterns"].get("bare_callable", 0) >= 1
371
372 def test_callable_returning_any_flagged(self, tmp_path: Path) -> None:
373 result = _scan("def f(cb: Callable[[int], Any]) -> None: ...\n", tmp_path)
374 assert result["patterns"].get("callable_any", 0) >= 1
375
376 def test_callable_with_full_signature_not_flagged(self, tmp_path: Path) -> None:
377 result = _scan("def f(cb: Callable[[int], str]) -> None: ...\n", tmp_path)
378 assert result["patterns"].get("bare_callable", 0) == 0
379 assert result["patterns"].get("callable_any", 0) == 0
380
381
382 # ---------------------------------------------------------------------------
383 # Untyped varargs (new in rewrite)
384 # ---------------------------------------------------------------------------
385
386
387 class TestVarargsPatterns:
388 def test_args_any_flagged(self, tmp_path: Path) -> None:
389 result = _scan("def f(*args: Any) -> None: ...\n", tmp_path)
390 assert result["patterns"].get("varargs_any", 0) >= 1
391
392 def test_kwargs_any_flagged(self, tmp_path: Path) -> None:
393 result = _scan("def f(**kwargs: Any) -> None: ...\n", tmp_path)
394 assert result["patterns"].get("varargs_any", 0) >= 1
395
396 def test_typed_args_not_flagged(self, tmp_path: Path) -> None:
397 result = _scan("def f(*args: int) -> None: ...\n", tmp_path)
398 assert result["patterns"].get("varargs_any", 0) == 0
399
400 def test_typed_kwargs_not_flagged(self, tmp_path: Path) -> None:
401 result = _scan("def f(**kwargs: str) -> None: ...\n", tmp_path)
402 assert result["patterns"].get("varargs_any", 0) == 0
403
404 def test_varargs_any_in_string_not_flagged(self, tmp_path: Path) -> None:
405 result = _scan('x = "*args: Any is bad"\n', tmp_path)
406 assert result["patterns"].get("varargs_any", 0) == 0
407
408
409 # ---------------------------------------------------------------------------
410 # _find_untyped_defs (AST-based)
411 # ---------------------------------------------------------------------------
412
413
414 class TestFindUntypedDefs:
415 def test_missing_return_type(self) -> None:
416 src = "def f():\n pass\n"
417 defs = _find_untyped_defs(src, "x.py")
418 assert any(d["issue"] == "missing_return_type" for d in defs)
419
420 def test_missing_param_type(self) -> None:
421 src = "def f(x) -> None:\n pass\n"
422 defs = _find_untyped_defs(src, "x.py")
423 assert any(d["issue"] == "missing_param_type" and "x" in d["name"] for d in defs)
424
425 def test_self_excluded(self) -> None:
426 src = "class C:\n def f(self) -> None:\n pass\n"
427 defs = _find_untyped_defs(src, "x.py")
428 assert not any("self" in d["name"] for d in defs)
429
430 def test_cls_excluded(self) -> None:
431 src = "class C:\n @classmethod\n def f(cls) -> None:\n pass\n"
432 defs = _find_untyped_defs(src, "x.py")
433 assert not any("cls" in d["name"] for d in defs)
434
435 def test_fully_typed_function_has_no_violations(self) -> None:
436 src = "def f(x: int, y: str) -> bool:\n return True\n"
437 defs = _find_untyped_defs(src, "x.py")
438 assert defs == []
439
440 def test_args_any_detected(self) -> None:
441 src = "def f(*args: Any) -> None:\n pass\n"
442 defs = _find_untyped_defs(src, "x.py")
443 issues = [d["issue"] for d in defs]
444 assert "untyped_args" in issues
445
446 def test_kwargs_any_detected(self) -> None:
447 src = "def f(**kwargs: Any) -> None:\n pass\n"
448 defs = _find_untyped_defs(src, "x.py")
449 issues = [d["issue"] for d in defs]
450 assert "untyped_kwargs" in issues
451
452 def test_syntax_error_returns_empty(self) -> None:
453 assert _find_untyped_defs("def f(: pass", "x.py") == []
454
455 def test_posonly_arg_without_annotation_flagged(self) -> None:
456 src = "def f(x, /, y: int) -> None:\n pass\n"
457 defs = _find_untyped_defs(src, "x.py")
458 assert any("x" in d["name"] for d in defs)
459
460 def test_kwonly_arg_without_annotation_flagged(self) -> None:
461 src = "def f(*, x) -> None:\n pass\n"
462 defs = _find_untyped_defs(src, "x.py")
463 assert any("x" in d["name"] for d in defs)
464
465
466 # ---------------------------------------------------------------------------
467 # scan_file edge cases
468 # ---------------------------------------------------------------------------
469
470
471 class TestScanFile:
472 def test_returns_none_for_missing_file(self, tmp_path: Path) -> None:
473 assert scan_file(tmp_path / "nonexistent.py") is None
474
475 def test_clean_file_has_zero_violations(self, tmp_path: Path) -> None:
476 result = _scan("def f(x: int) -> str:\n return str(x)\n", tmp_path)
477 assert _total(result) == 0
478
479 def test_file_path_in_result(self, tmp_path: Path) -> None:
480 p = _write(tmp_path, "check.py", "x = 1\n")
481 result = scan_file(p)
482 assert result is not None
483 assert result["file"] == str(p)
484
485 def test_imports_any_detected(self, tmp_path: Path) -> None:
486 result = _scan("from typing import Any\nx: Any = 1\n", tmp_path)
487 assert result["imports_any"] is True
488
489 def test_imports_any_false_for_no_any(self, tmp_path: Path) -> None:
490 result = _scan("x: int = 1\n", tmp_path)
491 assert result["imports_any"] is False
492
493 def test_multiple_violations_in_same_line(self, tmp_path: Path) -> None:
494 result = _scan("def f(x: Any) -> Any: ...\n", tmp_path)
495 assert _total(result) >= 2
496
497 def test_blank_and_comment_lines_skipped(self, tmp_path: Path) -> None:
498 """Blank lines and pure comments must not contribute to any violation."""
499 src = "\n# This has Any in a comment: Any\n\nx: int = 1\n"
500 result = _scan(src, tmp_path)
501 assert _total(result) == 0
502
503
504 # ---------------------------------------------------------------------------
505 # scan_directory
506 # ---------------------------------------------------------------------------
507
508
509 class TestScanDirectory:
510 def test_scans_all_py_files(self, tmp_path: Path) -> None:
511 _write(tmp_path, "a.py", "x: int = 1\n")
512 _write(tmp_path, "b.py", "def f() -> None: ...\n")
513 results = scan_directory(tmp_path)
514 assert len(results) == 2
515
516 def test_skips_venv(self, tmp_path: Path) -> None:
517 venv = tmp_path / "venv"
518 venv.mkdir()
519 _write(venv, "bad.py", "x: Any = 1\n")
520 _write(tmp_path, "good.py", "x: int = 1\n")
521 results = scan_directory(tmp_path)
522 assert len(results) == 1
523
524 def test_skips_pycache(self, tmp_path: Path) -> None:
525 cache = tmp_path / "__pycache__"
526 cache.mkdir()
527 _write(cache, "cached.py", "x: Any = 1\n")
528 _write(tmp_path, "real.py", "x: int = 1\n")
529 results = scan_directory(tmp_path)
530 assert len(results) == 1
531
532 def test_empty_directory(self, tmp_path: Path) -> None:
533 results = scan_directory(tmp_path)
534 assert results == []
535
536
537 # ---------------------------------------------------------------------------
538 # generate_report
539 # ---------------------------------------------------------------------------
540
541
542 class TestGenerateReport:
543 def _make_result(
544 self,
545 file: str = "x.py",
546 patterns: _PatternsMap | None = None,
547 imports_any: bool = False,
548 ) -> FileResult:
549 return FileResult(
550 file=file,
551 imports_any=imports_any,
552 patterns=patterns or {},
553 pattern_lines={},
554 type_ignore_variants={},
555 untyped_defs=[],
556 )
557
558 def test_empty_results_produce_zero_totals(self) -> None:
559 report = generate_report([])
560 assert report["summary"]["total_any_patterns"] == 0
561 assert report["summary"]["total_files_scanned"] == 0
562
563 def test_total_counts_aggregated(self) -> None:
564 r1 = self._make_result("a.py", {"param_any": 2})
565 r2 = self._make_result("b.py", {"return_any": 3})
566 report = generate_report([r1, r2])
567 assert report["summary"]["total_any_patterns"] == 5
568
569 def test_top_offenders_sorted_descending(self) -> None:
570 r1 = self._make_result("low.py", {"param_any": 1})
571 r2 = self._make_result("high.py", {"param_any": 10})
572 report = generate_report([r1, r2])
573 assert report["top_offenders"][0]["file"] == "high.py"
574
575 def test_files_importing_any_counted(self) -> None:
576 r1 = self._make_result("a.py", imports_any=True)
577 r2 = self._make_result("b.py", imports_any=False)
578 report = generate_report([r1, r2])
579 assert report["summary"]["files_importing_any"] == 1
580
581 def test_per_file_only_includes_violating_files(self) -> None:
582 r1 = self._make_result("clean.py", {})
583 r2 = self._make_result("dirty.py", {"param_any": 1})
584 report = generate_report([r1, r2])
585 assert "clean.py" not in report["per_file"]
586 assert "dirty.py" in report["per_file"]
587
588 def test_report_is_serialisable_to_json(self) -> None:
589 r = self._make_result("x.py", {"param_any": 1})
590 report = generate_report([r])
591 serialised = json.dumps(report)
592 loaded = json.loads(serialised)
593 assert loaded["summary"]["total_any_patterns"] == 1
594
595 def test_type_ignore_variants_aggregated(self) -> None:
596 r = FileResult(
597 file="x.py",
598 imports_any=False,
599 patterns={},
600 pattern_lines={},
601 type_ignore_variants={"type_ignore[blanket]": 3},
602 untyped_defs=[],
603 )
604 report = generate_report([r])
605 assert report["type_ignore_variants"]["type_ignore[blanket]"] == 3
606
607 def test_untyped_defs_aggregated(self) -> None:
608 ud = UntypedDef(file="x.py", line=1, name="f", issue="missing_return_type")
609 r = FileResult(
610 file="x.py",
611 imports_any=False,
612 patterns={},
613 pattern_lines={},
614 type_ignore_variants={},
615 untyped_defs=[ud],
616 )
617 report = generate_report([r])
618 assert report["summary"]["untyped_defs"] == 1
619 assert len(report["untyped_defs"]) == 1
620
621
622 # ---------------------------------------------------------------------------
623 # print_human_summary (smoke test)
624 # ---------------------------------------------------------------------------
625
626
627 class TestPrintHumanSummary:
628 def test_outputs_to_stdout(self, capsys: pytest.CaptureFixture[str]) -> None:
629 r = FileResult(
630 file="x.py",
631 imports_any=True,
632 patterns={"param_any": 2},
633 pattern_lines={"param_any": [10, 20]},
634 type_ignore_variants={},
635 untyped_defs=[],
636 )
637 report = generate_report([r])
638 print_human_summary(report)
639 out = capsys.readouterr().out
640 assert "TYPING AUDIT" in out
641 assert "param_any" in out
642
643 def test_no_violations_shows_none_label(
644 self, capsys: pytest.CaptureFixture[str]
645 ) -> None:
646 report = generate_report([])
647 print_human_summary(report)
648 out = capsys.readouterr().out
649 assert "(none)" in out
650
651 def test_type_ignore_variants_shown(
652 self, capsys: pytest.CaptureFixture[str]
653 ) -> None:
654 r = FileResult(
655 file="x.py",
656 imports_any=False,
657 patterns={"type_ignore": 1},
658 pattern_lines={"type_ignore": [5]},
659 type_ignore_variants={"type_ignore[blanket]": 1},
660 untyped_defs=[],
661 )
662 report = generate_report([r])
663 print_human_summary(report)
664 out = capsys.readouterr().out
665 assert "type_ignore[blanket]" in out
666
667
668 # ---------------------------------------------------------------------------
669 # CLI (via main() directly, capturing SystemExit)
670 # ---------------------------------------------------------------------------
671
672
673 class TestCLI:
674 def test_ratchet_passes_at_zero(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
675 _write(tmp_path, "clean.py", "def f(x: int) -> str:\n return str(x)\n")
676 monkeypatch.setattr(
677 sys, "argv",
678 ["typing_audit.py", "--dirs", str(tmp_path), "--max-any", "0"],
679 )
680 from typing_audit import main
681 main() # must not raise SystemExit
682
683 def test_ratchet_fails_above_threshold(
684 self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
685 ) -> None:
686 _write(tmp_path, "dirty.py", "def f(x: Any) -> None: ...\n")
687 monkeypatch.setattr(
688 sys, "argv",
689 ["typing_audit.py", "--dirs", str(tmp_path), "--max-any", "0"],
690 )
691 from typing_audit import main
692 with pytest.raises(SystemExit) as exc:
693 main()
694 assert exc.value.code == 1
695
696 def test_json_output_written(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
697 _write(tmp_path, "src.py", "def f(x: int) -> str:\n return str(x)\n")
698 out_json = tmp_path / "report.json"
699 monkeypatch.setattr(
700 sys, "argv",
701 ["typing_audit.py", "--dirs", str(tmp_path), "--json", str(out_json)],
702 )
703 from typing_audit import main
704 main()
705 assert out_json.exists()
706 data = json.loads(out_json.read_text())
707 assert "summary" in data
708
709 def test_missing_dir_warns_but_does_not_crash(
710 self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
711 ) -> None:
712 monkeypatch.setattr(
713 sys, "argv",
714 ["typing_audit.py", "--dirs", str(tmp_path / "nonexistent")],
715 )
716 from typing_audit import main
717 main() # must not raise
718 err = capsys.readouterr().err
719 assert "WARNING" in err
720
721 def test_single_file_arg_accepted(
722 self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
723 ) -> None:
724 p = _write(tmp_path, "single.py", "def f(x: int) -> None: ...\n")
725 monkeypatch.setattr(
726 sys, "argv",
727 ["typing_audit.py", "--dirs", str(p), "--max-any", "0"],
728 )
729 from typing_audit import main
730 main() # single file path accepted and scanned
731
732
733 # ---------------------------------------------------------------------------
734 # TypedDict shape invariants
735 # ---------------------------------------------------------------------------
736
737
738 class TestTypedDictShapes:
739 """Confirm that the TypedDicts have the exact keys callers depend on."""
740
741 def test_file_result_has_required_keys(self, tmp_path: Path) -> None:
742 result = _scan("x: int = 1\n", tmp_path)
743 for key in ("file", "imports_any", "patterns", "pattern_lines",
744 "type_ignore_variants", "untyped_defs"):
745 assert key in result, f"FileResult missing key: {key!r}"
746
747 def test_report_has_required_keys(self) -> None:
748 report = generate_report([])
749 for key in ("summary", "pattern_totals", "type_ignore_variants",
750 "top_offenders", "per_file", "untyped_defs"):
751 assert key in report, f"Report missing key: {key!r}"
752
753 def test_summary_has_required_keys(self) -> None:
754 report = generate_report([])
755 for key in ("total_files_scanned", "files_importing_any",
756 "total_any_patterns", "untyped_defs"):
757 assert key in report["summary"], f"ReportSummary missing key: {key!r}"
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 101 days ago