gabriel / muse public
test_typing_audit.py python
759 lines 29.4 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 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_not_flagged(self, tmp_path: Path) -> None:
264 """Code-specific ignores like type: ignore[assignment] are acceptable — not flagged."""
265 result = _scan("x: int = y # type: ignore[assignment]\n", tmp_path)
266 assert result["patterns"].get("type_ignore", 0) == 0
267
268 def test_type_ignore_in_string_not_flagged(self, tmp_path: Path) -> None:
269 """The text '# type: ignore' inside a print string is not a comment."""
270 result = _scan('print("use # type: ignore sparingly")\n', tmp_path)
271 assert result["patterns"].get("type_ignore", 0) == 0
272
273 def test_type_ignore_variant_classified(self, tmp_path: Path) -> None:
274 result = _scan("x = 1 # type: ignore\n", tmp_path)
275 assert result["type_ignore_variants"].get("type_ignore[blanket]", 0) >= 1
276
277 def test_specific_variant_not_classified(self, tmp_path: Path) -> None:
278 """Code-specific ignores don't match the blanket pattern — variants dict stays empty."""
279 result = _scan("x = 1 # type: ignore[attr-defined]\n", tmp_path)
280 assert result["type_ignore_variants"].get("type_ignore[attr-defined]", 0) == 0
281
282
283 # ---------------------------------------------------------------------------
284 # Bare collections
285 # ---------------------------------------------------------------------------
286
287
288 class TestBareCollections:
289 def test_bare_list_in_annotation(self, tmp_path: Path) -> None:
290 result = _scan("def f() -> list: ...\n", tmp_path)
291 assert result["patterns"].get("bare_list", 0) >= 1
292
293 def test_bare_dict_in_annotation(self, tmp_path: Path) -> None:
294 result = _scan("def f() -> dict: ...\n", tmp_path)
295 assert result["patterns"].get("bare_dict", 0) >= 1
296
297 def test_bare_set_in_annotation(self, tmp_path: Path) -> None:
298 result = _scan("x: set = set()\n", tmp_path)
299 assert result["patterns"].get("bare_set", 0) >= 1
300
301 def test_bare_tuple_in_annotation(self, tmp_path: Path) -> None:
302 result = _scan("x: tuple = (1, 2)\n", tmp_path)
303 assert result["patterns"].get("bare_tuple", 0) >= 1
304
305 def test_parameterised_list_not_flagged(self, tmp_path: Path) -> None:
306 result = _scan("def f() -> list[int]: ...\n", tmp_path)
307 assert result["patterns"].get("bare_list", 0) == 0
308
309 def test_parameterised_dict_not_flagged(self, tmp_path: Path) -> None:
310 result = _scan("x: dict[str, int] = {}\n", tmp_path)
311 assert result["patterns"].get("bare_dict", 0) == 0
312
313
314 # ---------------------------------------------------------------------------
315 # Optional / Union (legacy)
316 # ---------------------------------------------------------------------------
317
318
319 class TestOptionalUnion:
320 def test_optional_usage_flagged(self, tmp_path: Path) -> None:
321 result = _scan("def f(x: Optional[int]) -> None: ...\n", tmp_path)
322 assert result["patterns"].get("optional_usage", 0) >= 1
323
324 def test_union_usage_flagged(self, tmp_path: Path) -> None:
325 result = _scan("def f(x: Union[int, str]) -> None: ...\n", tmp_path)
326 assert result["patterns"].get("union_usage", 0) >= 1
327
328 def test_pipe_union_not_flagged(self, tmp_path: Path) -> None:
329 result = _scan("def f(x: int | str) -> None: ...\n", tmp_path)
330 assert _total(result) == 0
331
332 def test_x_or_none_not_flagged(self, tmp_path: Path) -> None:
333 result = _scan("def f(x: int | None) -> None: ...\n", tmp_path)
334 assert _total(result) == 0
335
336
337 # ---------------------------------------------------------------------------
338 # Legacy typing imports
339 # ---------------------------------------------------------------------------
340
341
342 class TestLegacyImports:
343 def test_legacy_List_flagged(self, tmp_path: Path) -> None:
344 result = _scan("from typing import List\nx: List[int] = []\n", tmp_path)
345 assert result["patterns"].get("legacy_List", 0) >= 1
346
347 def test_legacy_Dict_flagged(self, tmp_path: Path) -> None:
348 result = _scan("from typing import Dict\nx: Dict[str, int] = {}\n", tmp_path)
349 assert result["patterns"].get("legacy_Dict", 0) >= 1
350
351 def test_legacy_Set_flagged(self, tmp_path: Path) -> None:
352 result = _scan("from typing import Set\nx: Set[int] = set()\n", tmp_path)
353 assert result["patterns"].get("legacy_Set", 0) >= 1
354
355 def test_legacy_Tuple_flagged(self, tmp_path: Path) -> None:
356 result = _scan("from typing import Tuple\nx: Tuple[int, str] = (1, 'a')\n", tmp_path)
357 assert result["patterns"].get("legacy_Tuple", 0) >= 1
358
359
360 # ---------------------------------------------------------------------------
361 # Callable patterns (new in rewrite)
362 # ---------------------------------------------------------------------------
363
364
365 class TestCallablePatterns:
366 def test_bare_callable_in_annotation_flagged(self, tmp_path: Path) -> None:
367 result = _scan("def f(cb: Callable) -> None: ...\n", tmp_path)
368 assert result["patterns"].get("bare_callable", 0) >= 1
369
370 def test_bare_callable_as_return_flagged(self, tmp_path: Path) -> None:
371 result = _scan("def f() -> Callable: ...\n", tmp_path)
372 assert result["patterns"].get("bare_callable", 0) >= 1
373
374 def test_callable_returning_any_flagged(self, tmp_path: Path) -> None:
375 result = _scan("def f(cb: Callable[[int], Any]) -> None: ...\n", tmp_path)
376 assert result["patterns"].get("callable_any", 0) >= 1
377
378 def test_callable_with_full_signature_not_flagged(self, tmp_path: Path) -> None:
379 result = _scan("def f(cb: Callable[[int], str]) -> None: ...\n", tmp_path)
380 assert result["patterns"].get("bare_callable", 0) == 0
381 assert result["patterns"].get("callable_any", 0) == 0
382
383
384 # ---------------------------------------------------------------------------
385 # Untyped varargs (new in rewrite)
386 # ---------------------------------------------------------------------------
387
388
389 class TestVarargsPatterns:
390 def test_args_any_flagged(self, tmp_path: Path) -> None:
391 result = _scan("def f(*args: Any) -> None: ...\n", tmp_path)
392 assert result["patterns"].get("varargs_any", 0) >= 1
393
394 def test_kwargs_any_flagged(self, tmp_path: Path) -> None:
395 result = _scan("def f(**kwargs: Any) -> None: ...\n", tmp_path)
396 assert result["patterns"].get("varargs_any", 0) >= 1
397
398 def test_typed_args_not_flagged(self, tmp_path: Path) -> None:
399 result = _scan("def f(*args: int) -> None: ...\n", tmp_path)
400 assert result["patterns"].get("varargs_any", 0) == 0
401
402 def test_typed_kwargs_not_flagged(self, tmp_path: Path) -> None:
403 result = _scan("def f(**kwargs: str) -> None: ...\n", tmp_path)
404 assert result["patterns"].get("varargs_any", 0) == 0
405
406 def test_varargs_any_in_string_not_flagged(self, tmp_path: Path) -> None:
407 result = _scan('x = "*args: Any is bad"\n', tmp_path)
408 assert result["patterns"].get("varargs_any", 0) == 0
409
410
411 # ---------------------------------------------------------------------------
412 # _find_untyped_defs (AST-based)
413 # ---------------------------------------------------------------------------
414
415
416 class TestFindUntypedDefs:
417 def test_missing_return_type(self) -> None:
418 src = "def f():\n pass\n"
419 defs = _find_untyped_defs(src, "x.py")
420 assert any(d["issue"] == "missing_return_type" for d in defs)
421
422 def test_missing_param_type(self) -> None:
423 src = "def f(x) -> None:\n pass\n"
424 defs = _find_untyped_defs(src, "x.py")
425 assert any(d["issue"] == "missing_param_type" and "x" in d["name"] for d in defs)
426
427 def test_self_excluded(self) -> None:
428 src = "class C:\n def f(self) -> None:\n pass\n"
429 defs = _find_untyped_defs(src, "x.py")
430 assert not any("self" in d["name"] for d in defs)
431
432 def test_cls_excluded(self) -> None:
433 src = "class C:\n @classmethod\n def f(cls) -> None:\n pass\n"
434 defs = _find_untyped_defs(src, "x.py")
435 assert not any("cls" in d["name"] for d in defs)
436
437 def test_fully_typed_function_has_no_violations(self) -> None:
438 src = "def f(x: int, y: str) -> bool:\n return True\n"
439 defs = _find_untyped_defs(src, "x.py")
440 assert defs == []
441
442 def test_args_any_detected(self) -> None:
443 src = "def f(*args: Any) -> None:\n pass\n"
444 defs = _find_untyped_defs(src, "x.py")
445 issues = [d["issue"] for d in defs]
446 assert "untyped_args" in issues
447
448 def test_kwargs_any_detected(self) -> None:
449 src = "def f(**kwargs: Any) -> None:\n pass\n"
450 defs = _find_untyped_defs(src, "x.py")
451 issues = [d["issue"] for d in defs]
452 assert "untyped_kwargs" in issues
453
454 def test_syntax_error_returns_empty(self) -> None:
455 assert _find_untyped_defs("def f(: pass", "x.py") == []
456
457 def test_posonly_arg_without_annotation_flagged(self) -> None:
458 src = "def f(x, /, y: int) -> None:\n pass\n"
459 defs = _find_untyped_defs(src, "x.py")
460 assert any("x" in d["name"] for d in defs)
461
462 def test_kwonly_arg_without_annotation_flagged(self) -> None:
463 src = "def f(*, x) -> None:\n pass\n"
464 defs = _find_untyped_defs(src, "x.py")
465 assert any("x" in d["name"] for d in defs)
466
467
468 # ---------------------------------------------------------------------------
469 # scan_file edge cases
470 # ---------------------------------------------------------------------------
471
472
473 class TestScanFile:
474 def test_returns_none_for_missing_file(self, tmp_path: Path) -> None:
475 assert scan_file(tmp_path / "nonexistent.py") is None
476
477 def test_clean_file_has_zero_violations(self, tmp_path: Path) -> None:
478 result = _scan("def f(x: int) -> str:\n return str(x)\n", tmp_path)
479 assert _total(result) == 0
480
481 def test_file_path_in_result(self, tmp_path: Path) -> None:
482 p = _write(tmp_path, "check.py", "x = 1\n")
483 result = scan_file(p)
484 assert result is not None
485 assert result["file"] == str(p)
486
487 def test_imports_any_detected(self, tmp_path: Path) -> None:
488 result = _scan("from typing import Any\nx: Any = 1\n", tmp_path)
489 assert result["imports_any"] is True
490
491 def test_imports_any_false_for_no_any(self, tmp_path: Path) -> None:
492 result = _scan("x: int = 1\n", tmp_path)
493 assert result["imports_any"] is False
494
495 def test_multiple_violations_in_same_line(self, tmp_path: Path) -> None:
496 result = _scan("def f(x: Any) -> Any: ...\n", tmp_path)
497 assert _total(result) >= 2
498
499 def test_blank_and_comment_lines_skipped(self, tmp_path: Path) -> None:
500 """Blank lines and pure comments must not contribute to any violation."""
501 src = "\n# This has Any in a comment: Any\n\nx: int = 1\n"
502 result = _scan(src, tmp_path)
503 assert _total(result) == 0
504
505
506 # ---------------------------------------------------------------------------
507 # scan_directory
508 # ---------------------------------------------------------------------------
509
510
511 class TestScanDirectory:
512 def test_scans_all_py_files(self, tmp_path: Path) -> None:
513 _write(tmp_path, "a.py", "x: int = 1\n")
514 _write(tmp_path, "b.py", "def f() -> None: ...\n")
515 results = scan_directory(tmp_path)
516 assert len(results) == 2
517
518 def test_skips_venv(self, tmp_path: Path) -> None:
519 venv = tmp_path / "venv"
520 venv.mkdir()
521 _write(venv, "bad.py", "x: Any = 1\n")
522 _write(tmp_path, "good.py", "x: int = 1\n")
523 results = scan_directory(tmp_path)
524 assert len(results) == 1
525
526 def test_skips_pycache(self, tmp_path: Path) -> None:
527 cache = tmp_path / "__pycache__"
528 cache.mkdir()
529 _write(cache, "cached.py", "x: Any = 1\n")
530 _write(tmp_path, "real.py", "x: int = 1\n")
531 results = scan_directory(tmp_path)
532 assert len(results) == 1
533
534 def test_empty_directory(self, tmp_path: Path) -> None:
535 results = scan_directory(tmp_path)
536 assert results == []
537
538
539 # ---------------------------------------------------------------------------
540 # generate_report
541 # ---------------------------------------------------------------------------
542
543
544 class TestGenerateReport:
545 def _make_result(
546 self,
547 file: str = "x.py",
548 patterns: _PatternsMap | None = None,
549 imports_any: bool = False,
550 ) -> FileResult:
551 return FileResult(
552 file=file,
553 imports_any=imports_any,
554 patterns=patterns or {},
555 pattern_lines={},
556 type_ignore_variants={},
557 untyped_defs=[],
558 )
559
560 def test_empty_results_produce_zero_totals(self) -> None:
561 report = generate_report([])
562 assert report["summary"]["total_any_patterns"] == 0
563 assert report["summary"]["total_files_scanned"] == 0
564
565 def test_total_counts_aggregated(self) -> None:
566 r1 = self._make_result("a.py", {"param_any": 2})
567 r2 = self._make_result("b.py", {"return_any": 3})
568 report = generate_report([r1, r2])
569 assert report["summary"]["total_any_patterns"] == 5
570
571 def test_top_offenders_sorted_descending(self) -> None:
572 r1 = self._make_result("low.py", {"param_any": 1})
573 r2 = self._make_result("high.py", {"param_any": 10})
574 report = generate_report([r1, r2])
575 assert report["top_offenders"][0]["file"] == "high.py"
576
577 def test_files_importing_any_counted(self) -> None:
578 r1 = self._make_result("a.py", imports_any=True)
579 r2 = self._make_result("b.py", imports_any=False)
580 report = generate_report([r1, r2])
581 assert report["summary"]["files_importing_any"] == 1
582
583 def test_per_file_only_includes_violating_files(self) -> None:
584 r1 = self._make_result("clean.py", {})
585 r2 = self._make_result("dirty.py", {"param_any": 1})
586 report = generate_report([r1, r2])
587 assert "clean.py" not in report["per_file"]
588 assert "dirty.py" in report["per_file"]
589
590 def test_report_is_serialisable_to_json(self) -> None:
591 r = self._make_result("x.py", {"param_any": 1})
592 report = generate_report([r])
593 serialised = json.dumps(report)
594 loaded = json.loads(serialised)
595 assert loaded["summary"]["total_any_patterns"] == 1
596
597 def test_type_ignore_variants_aggregated(self) -> None:
598 r = FileResult(
599 file="x.py",
600 imports_any=False,
601 patterns={},
602 pattern_lines={},
603 type_ignore_variants={"type_ignore[blanket]": 3},
604 untyped_defs=[],
605 )
606 report = generate_report([r])
607 assert report["type_ignore_variants"]["type_ignore[blanket]"] == 3
608
609 def test_untyped_defs_aggregated(self) -> None:
610 ud = UntypedDef(file="x.py", line=1, name="f", issue="missing_return_type")
611 r = FileResult(
612 file="x.py",
613 imports_any=False,
614 patterns={},
615 pattern_lines={},
616 type_ignore_variants={},
617 untyped_defs=[ud],
618 )
619 report = generate_report([r])
620 assert report["summary"]["untyped_defs"] == 1
621 assert len(report["untyped_defs"]) == 1
622
623
624 # ---------------------------------------------------------------------------
625 # print_human_summary (smoke test)
626 # ---------------------------------------------------------------------------
627
628
629 class TestPrintHumanSummary:
630 def test_outputs_to_stdout(self, capsys: pytest.CaptureFixture[str]) -> None:
631 r = FileResult(
632 file="x.py",
633 imports_any=True,
634 patterns={"param_any": 2},
635 pattern_lines={"param_any": [10, 20]},
636 type_ignore_variants={},
637 untyped_defs=[],
638 )
639 report = generate_report([r])
640 print_human_summary(report)
641 out = capsys.readouterr().out
642 assert "TYPING AUDIT" in out
643 assert "param_any" in out
644
645 def test_no_violations_shows_none_label(
646 self, capsys: pytest.CaptureFixture[str]
647 ) -> None:
648 report = generate_report([])
649 print_human_summary(report)
650 out = capsys.readouterr().out
651 assert "(none)" in out
652
653 def test_type_ignore_variants_shown(
654 self, capsys: pytest.CaptureFixture[str]
655 ) -> None:
656 r = FileResult(
657 file="x.py",
658 imports_any=False,
659 patterns={"type_ignore": 1},
660 pattern_lines={"type_ignore": [5]},
661 type_ignore_variants={"type_ignore[blanket]": 1},
662 untyped_defs=[],
663 )
664 report = generate_report([r])
665 print_human_summary(report)
666 out = capsys.readouterr().out
667 assert "type_ignore[blanket]" in out
668
669
670 # ---------------------------------------------------------------------------
671 # CLI (via main() directly, capturing SystemExit)
672 # ---------------------------------------------------------------------------
673
674
675 class TestCLI:
676 def test_ratchet_passes_at_zero(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
677 _write(tmp_path, "clean.py", "def f(x: int) -> str:\n return str(x)\n")
678 monkeypatch.setattr(
679 sys, "argv",
680 ["typing_audit.py", "--dirs", str(tmp_path), "--max-any", "0"],
681 )
682 from typing_audit import main
683 main() # must not raise SystemExit
684
685 def test_ratchet_fails_above_threshold(
686 self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
687 ) -> None:
688 _write(tmp_path, "dirty.py", "def f(x: Any) -> None: ...\n")
689 monkeypatch.setattr(
690 sys, "argv",
691 ["typing_audit.py", "--dirs", str(tmp_path), "--max-any", "0"],
692 )
693 from typing_audit import main
694 with pytest.raises(SystemExit) as exc:
695 main()
696 assert exc.value.code == 1
697
698 def test_json_output_written(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
699 _write(tmp_path, "src.py", "def f(x: int) -> str:\n return str(x)\n")
700 out_json = tmp_path / "report.json"
701 monkeypatch.setattr(
702 sys, "argv",
703 ["typing_audit.py", "--dirs", str(tmp_path), "--json", str(out_json)],
704 )
705 from typing_audit import main
706 main()
707 assert out_json.exists()
708 data = json.loads(out_json.read_text())
709 assert "summary" in data
710
711 def test_missing_dir_warns_but_does_not_crash(
712 self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
713 ) -> None:
714 monkeypatch.setattr(
715 sys, "argv",
716 ["typing_audit.py", "--dirs", str(tmp_path / "nonexistent")],
717 )
718 from typing_audit import main
719 main() # must not raise
720 err = capsys.readouterr().err
721 assert "WARNING" in err
722
723 def test_single_file_arg_accepted(
724 self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
725 ) -> None:
726 p = _write(tmp_path, "single.py", "def f(x: int) -> None: ...\n")
727 monkeypatch.setattr(
728 sys, "argv",
729 ["typing_audit.py", "--dirs", str(p), "--max-any", "0"],
730 )
731 from typing_audit import main
732 main() # single file path accepted and scanned
733
734
735 # ---------------------------------------------------------------------------
736 # TypedDict shape invariants
737 # ---------------------------------------------------------------------------
738
739
740 class TestTypedDictShapes:
741 """Confirm that the TypedDicts have the exact keys callers depend on."""
742
743 def test_file_result_has_required_keys(self, tmp_path: Path) -> None:
744 result = _scan("x: int = 1\n", tmp_path)
745 for key in ("file", "imports_any", "patterns", "pattern_lines",
746 "type_ignore_variants", "untyped_defs"):
747 assert key in result, f"FileResult missing key: {key!r}"
748
749 def test_report_has_required_keys(self) -> None:
750 report = generate_report([])
751 for key in ("summary", "pattern_totals", "type_ignore_variants",
752 "top_offenders", "per_file", "untyped_defs"):
753 assert key in report, f"Report missing key: {key!r}"
754
755 def test_summary_has_required_keys(self) -> None:
756 report = generate_report([])
757 for key in ("total_files_scanned", "files_importing_any",
758 "total_any_patterns", "untyped_defs"):
759 assert key in report["summary"], f"ReportSummary missing key: {key!r}"
File History 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 22 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 28 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 29 days ago