serenecode 0.1.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.
Files changed (39) hide show
  1. serenecode/__init__.py +281 -0
  2. serenecode/adapters/__init__.py +6 -0
  3. serenecode/adapters/coverage_adapter.py +1173 -0
  4. serenecode/adapters/crosshair_adapter.py +1069 -0
  5. serenecode/adapters/hypothesis_adapter.py +1824 -0
  6. serenecode/adapters/local_fs.py +169 -0
  7. serenecode/adapters/module_loader.py +492 -0
  8. serenecode/adapters/mypy_adapter.py +161 -0
  9. serenecode/checker/__init__.py +6 -0
  10. serenecode/checker/compositional.py +2216 -0
  11. serenecode/checker/coverage.py +186 -0
  12. serenecode/checker/properties.py +154 -0
  13. serenecode/checker/structural.py +1504 -0
  14. serenecode/checker/symbolic.py +178 -0
  15. serenecode/checker/types.py +148 -0
  16. serenecode/cli.py +478 -0
  17. serenecode/config.py +711 -0
  18. serenecode/contracts/__init__.py +6 -0
  19. serenecode/contracts/predicates.py +176 -0
  20. serenecode/core/__init__.py +6 -0
  21. serenecode/core/exceptions.py +38 -0
  22. serenecode/core/pipeline.py +807 -0
  23. serenecode/init.py +307 -0
  24. serenecode/models.py +308 -0
  25. serenecode/ports/__init__.py +6 -0
  26. serenecode/ports/coverage_analyzer.py +124 -0
  27. serenecode/ports/file_system.py +95 -0
  28. serenecode/ports/property_tester.py +69 -0
  29. serenecode/ports/symbolic_checker.py +70 -0
  30. serenecode/ports/type_checker.py +66 -0
  31. serenecode/reporter.py +346 -0
  32. serenecode/source_discovery.py +319 -0
  33. serenecode/templates/__init__.py +5 -0
  34. serenecode/templates/content.py +337 -0
  35. serenecode-0.1.0.dist-info/METADATA +298 -0
  36. serenecode-0.1.0.dist-info/RECORD +39 -0
  37. serenecode-0.1.0.dist-info/WHEEL +4 -0
  38. serenecode-0.1.0.dist-info/entry_points.txt +2 -0
  39. serenecode-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1173 @@
1
+ """Coverage analysis adapter for Level 3.
2
+
3
+ This adapter implements the CoverageAnalyzer protocol by running
4
+ existing tests under coverage.py tracing, analyzing per-function
5
+ coverage, and generating test suggestions for uncovered paths
6
+ with mock necessity assessments.
7
+
8
+ This is an adapter module — it handles I/O (module importing, test
9
+ execution, subprocess calls) and is exempt from full contract requirements.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import ast
15
+ import inspect
16
+ import json
17
+ import os
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import icontract
26
+
27
+ from serenecode.adapters.module_loader import load_python_module
28
+ from serenecode.contracts.predicates import is_non_empty_string
29
+ from serenecode.core.exceptions import ToolNotInstalledError, UnsafeCodeExecutionError
30
+ from serenecode.ports.coverage_analyzer import (
31
+ CoverageFinding,
32
+ MockDependency,
33
+ TestSuggestion,
34
+ )
35
+
36
+ try:
37
+ import coverage as coverage_lib
38
+ _COVERAGE_AVAILABLE = True
39
+ except ImportError:
40
+ _COVERAGE_AVAILABLE = False
41
+
42
+ _TRUST_REQUIRED_MESSAGE = (
43
+ "Level 3 coverage analysis imports and executes project modules. "
44
+ "Re-run with allow_code_execution=True only for trusted code."
45
+ )
46
+
47
+ # I/O modules that always require mocking in tests
48
+ _IO_MODULES = frozenset({
49
+ "os", "pathlib", "subprocess", "requests", "socket", "shutil",
50
+ "tempfile", "glob", "http", "urllib", "sqlite3", "smtplib",
51
+ "ftplib", "aiohttp", "boto3", "redis", "sqlalchemy",
52
+ "httpx", "grpc", "paramiko", "fabric",
53
+ })
54
+
55
+ # I/O function/method patterns that suggest external interaction.
56
+ # Intentionally excludes generic names like "get", "post", "put", "delete",
57
+ # "patch" which match dict methods and other benign internal code.
58
+ _IO_CALL_PATTERNS = frozenset({
59
+ "open", "read_file", "write_file", "connect", "send", "recv",
60
+ "execute", "fetchall", "fetchone", "commit", "rollback",
61
+ "request", "urlopen", "makefile", "sendall", "sendto",
62
+ })
63
+
64
+
65
+ @icontract.invariant(
66
+ lambda self: len(self.name) > 0 and self.line_start >= 1,
67
+ "name must be non-empty and line_start must be >= 1",
68
+ )
69
+ @dataclass(frozen=True)
70
+ class _FunctionNode:
71
+ """AST-extracted function information."""
72
+
73
+ name: str
74
+ qualified_name: str
75
+ line_start: int
76
+ line_end: int
77
+ is_method: bool
78
+ class_name: str | None
79
+
80
+
81
+ @icontract.invariant(
82
+ lambda self: self.total_lines >= 0,
83
+ "total_lines must be non-negative",
84
+ )
85
+ @dataclass(frozen=True)
86
+ class _FunctionCoverage:
87
+ """Coverage metrics for a single function."""
88
+
89
+ function: _FunctionNode
90
+ total_lines: int
91
+ executed_lines: frozenset[int]
92
+ missing_lines: frozenset[int]
93
+ total_branches: int
94
+ executed_branches: tuple[tuple[int, int], ...]
95
+ missing_branches: tuple[tuple[int, int], ...]
96
+
97
+
98
+ # no-invariant: adapter with mutable coverage cache for single-run optimization
99
+ class CoverageAnalyzerAdapter:
100
+ """Coverage analysis implementation using coverage.py.
101
+
102
+ Runs existing tests with coverage tracing, analyzes per-function
103
+ coverage, and generates test suggestions for uncovered paths.
104
+
105
+ Coverage data is cached per project root so that multiple modules
106
+ in the same project share a single pytest run.
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ allow_code_execution: bool = False,
112
+ coverage_threshold: float = 80.0,
113
+ ) -> None:
114
+ """Initialize the coverage analyzer.
115
+
116
+ Args:
117
+ allow_code_execution: Must be True to run tests.
118
+ coverage_threshold: Default coverage threshold percentage.
119
+ """
120
+ if not allow_code_execution:
121
+ raise UnsafeCodeExecutionError(_TRUST_REQUIRED_MESSAGE)
122
+ if not _COVERAGE_AVAILABLE:
123
+ raise ToolNotInstalledError(
124
+ "coverage is not installed. Install with: pip install coverage"
125
+ )
126
+ self._allow_code_execution = allow_code_execution
127
+ self._coverage_threshold = coverage_threshold
128
+ self._coverage_cache: dict[str, dict[str, Any]] = {}
129
+
130
+ @icontract.require(
131
+ lambda self: self._allow_code_execution,
132
+ "code execution must be explicitly allowed",
133
+ )
134
+ @icontract.require(
135
+ lambda module_path: is_non_empty_string(module_path),
136
+ "module_path must be a non-empty string",
137
+ )
138
+ @icontract.require(
139
+ lambda search_paths: isinstance(search_paths, tuple),
140
+ "search_paths must be a tuple",
141
+ )
142
+ @icontract.ensure(lambda result: isinstance(result, list), "result must be a list")
143
+ def analyze_module(
144
+ self,
145
+ module_path: str,
146
+ search_paths: tuple[str, ...] = (),
147
+ coverage_threshold: float = 80.0,
148
+ ) -> list[CoverageFinding]:
149
+ """Run coverage analysis on all functions in a module.
150
+
151
+ Args:
152
+ module_path: Importable Python module path to analyze.
153
+ search_paths: sys.path roots needed to import the module.
154
+ coverage_threshold: Minimum coverage percentage to pass.
155
+
156
+ Returns:
157
+ List of coverage findings per function.
158
+ """
159
+ effective_threshold = coverage_threshold
160
+
161
+ # Step 1: Resolve module to file
162
+ module = load_python_module(module_path, search_paths)
163
+ try:
164
+ # Use getsourcefile to get the .py path, not .pyc bytecode path
165
+ source_file_result = inspect.getsourcefile(module)
166
+ if source_file_result is None:
167
+ source_file = inspect.getfile(module)
168
+ else:
169
+ source_file = source_file_result
170
+ except (TypeError, OSError):
171
+ return []
172
+ source_file = str(Path(source_file).resolve())
173
+
174
+ try:
175
+ source = Path(source_file).read_text(encoding="utf-8")
176
+ except OSError:
177
+ return []
178
+
179
+ # Step 2: Discover functions via AST
180
+ functions = _discover_functions(source)
181
+ if not functions:
182
+ return []
183
+
184
+ # Step 3: Run tests with coverage (cached per project root)
185
+ project_root = _find_project_root(source_file, search_paths)
186
+ cache_key = project_root or source_file
187
+ if cache_key in self._coverage_cache:
188
+ coverage_data = self._coverage_cache[cache_key]
189
+ else:
190
+ coverage_data = _run_tests_with_coverage(source_file, search_paths)
191
+ self._coverage_cache[cache_key] = coverage_data
192
+
193
+ # Check for timeout or other errors signalled by _run_tests_with_coverage.
194
+ error_msg = coverage_data.get("_error")
195
+ if isinstance(error_msg, str):
196
+ return [CoverageFinding(
197
+ function_name="<module>",
198
+ module_path=module_path,
199
+ line_start=1,
200
+ line_end=1,
201
+ line_coverage_percent=0.0,
202
+ branch_coverage_percent=0.0,
203
+ uncovered_lines=(),
204
+ uncovered_branches=(),
205
+ suggestions=(),
206
+ meets_threshold=False,
207
+ message=f"Coverage analysis failed for '{module_path}': {error_msg}",
208
+ )]
209
+
210
+ # If no coverage data at all (no tests found or pytest failed), report
211
+ # as informational rather than failing — the purpose of L3 is to analyze
212
+ # existing test coverage, not to fail when tests haven't been written yet.
213
+ # Check if any lines were actually executed — if zero lines were
214
+ # covered across all files, no tests exercised this module.
215
+ total_covered = sum(
216
+ len(f.get("executed_lines", []))
217
+ for f in coverage_data.get("files", {}).values()
218
+ ) if coverage_data else 0
219
+ if not coverage_data or total_covered == 0:
220
+ return [CoverageFinding(
221
+ function_name="<module>",
222
+ module_path=module_path,
223
+ line_start=1,
224
+ line_end=1,
225
+ line_coverage_percent=0.0,
226
+ branch_coverage_percent=0.0,
227
+ uncovered_lines=(),
228
+ uncovered_branches=(),
229
+ suggestions=(),
230
+ meets_threshold=False,
231
+ message=(
232
+ f"No test coverage data for '{module_path}' — "
233
+ "no tests found. Write tests to enable coverage analysis."
234
+ ),
235
+ )]
236
+
237
+ # Step 4: Map coverage to functions
238
+ function_coverages = _map_coverage_to_functions(
239
+ coverage_data, functions, source_file,
240
+ )
241
+
242
+ # Step 5 & 6: Analyze uncovered paths and generate suggestions
243
+ findings: list[CoverageFinding] = []
244
+ # Loop invariant: findings contains results for function_coverages[0..i]
245
+ for fc in function_coverages:
246
+ line_pct = (
247
+ 100.0 * len(fc.executed_lines) / fc.total_lines
248
+ if fc.total_lines > 0
249
+ else 100.0
250
+ )
251
+ branch_pct = (
252
+ 100.0 * len(fc.executed_branches) / fc.total_branches
253
+ if fc.total_branches > 0
254
+ else 100.0
255
+ )
256
+ meets = line_pct >= effective_threshold and branch_pct >= effective_threshold
257
+
258
+ suggestions: tuple[TestSuggestion, ...] = ()
259
+ if not meets and fc.missing_lines:
260
+ suggestions = _generate_suggestions(
261
+ fc, source, module_path,
262
+ )
263
+
264
+ if meets:
265
+ message = (
266
+ f"'{fc.function.qualified_name}' has {line_pct:.0f}% line coverage "
267
+ f"and {branch_pct:.0f}% branch coverage (threshold: {effective_threshold:.0f}%)"
268
+ )
269
+ else:
270
+ uncov_count = len(fc.missing_lines)
271
+ message = (
272
+ f"'{fc.function.qualified_name}' has {line_pct:.0f}% line coverage "
273
+ f"and {branch_pct:.0f}% branch coverage — "
274
+ f"{uncov_count} lines uncovered (threshold: {effective_threshold:.0f}%)"
275
+ )
276
+
277
+ findings.append(CoverageFinding(
278
+ function_name=fc.function.qualified_name,
279
+ module_path=module_path,
280
+ line_start=fc.function.line_start,
281
+ line_end=fc.function.line_end,
282
+ line_coverage_percent=line_pct,
283
+ branch_coverage_percent=branch_pct,
284
+ uncovered_lines=tuple(sorted(fc.missing_lines)),
285
+ uncovered_branches=fc.missing_branches,
286
+ suggestions=suggestions,
287
+ meets_threshold=meets,
288
+ message=message,
289
+ ))
290
+
291
+ return findings
292
+
293
+
294
+ @icontract.require(
295
+ lambda source: isinstance(source, str),
296
+ "source must be a string",
297
+ )
298
+ @icontract.ensure(lambda result: isinstance(result, list), "result must be a list")
299
+ def _discover_functions(source: str) -> list[_FunctionNode]:
300
+ """Parse source and extract all function definitions with line ranges.
301
+
302
+ Recursively walks the AST to discover nested functions and methods
303
+ in nested classes, not just top-level definitions.
304
+
305
+ Args:
306
+ source: Python source code.
307
+
308
+ Returns:
309
+ List of function nodes with name, line range, and class context.
310
+ """
311
+ try:
312
+ tree = ast.parse(source)
313
+ except SyntaxError:
314
+ return []
315
+
316
+ functions: list[_FunctionNode] = []
317
+ _walk_for_functions(tree, functions, prefix="", class_name=None)
318
+ return functions
319
+
320
+
321
+ @icontract.require(
322
+ lambda node: isinstance(node, ast.AST),
323
+ "node must be an AST node",
324
+ )
325
+ @icontract.require(
326
+ lambda functions: isinstance(functions, list),
327
+ "functions must be a list",
328
+ )
329
+ @icontract.require(
330
+ lambda prefix: isinstance(prefix, str),
331
+ "prefix must be a string",
332
+ )
333
+ @icontract.ensure(
334
+ lambda result: result is None,
335
+ "function mutates the accumulator list in place",
336
+ )
337
+ def _walk_for_functions(
338
+ node: ast.AST,
339
+ functions: list[_FunctionNode],
340
+ prefix: str,
341
+ class_name: str | None,
342
+ ) -> None:
343
+ """Recursively discover function definitions in an AST subtree.
344
+
345
+ Args:
346
+ node: Current AST node to examine.
347
+ functions: Accumulator list for discovered functions.
348
+ prefix: Dotted qualified name prefix from enclosing scopes.
349
+ class_name: Enclosing class name if inside a class, None otherwise.
350
+ """
351
+ # Variant: AST depth decreases with each recursive call
352
+ for child in ast.iter_child_nodes(node):
353
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
354
+ qualified = f"{prefix}.{child.name}" if prefix else child.name
355
+ functions.append(_FunctionNode(
356
+ name=child.name,
357
+ qualified_name=qualified,
358
+ line_start=child.lineno,
359
+ line_end=child.end_lineno or child.lineno,
360
+ is_method=class_name is not None,
361
+ class_name=class_name,
362
+ ))
363
+ # Recurse into function body for nested functions
364
+ _walk_for_functions(child, functions, prefix=qualified, class_name=None)
365
+ elif isinstance(child, ast.ClassDef):
366
+ qualified = f"{prefix}.{child.name}" if prefix else child.name
367
+ # Recurse into class body for methods and nested classes
368
+ _walk_for_functions(child, functions, prefix=qualified, class_name=child.name)
369
+
370
+
371
+ @icontract.require(
372
+ lambda source_file: is_non_empty_string(source_file),
373
+ "source_file must be a non-empty string",
374
+ )
375
+ @icontract.require(
376
+ lambda search_paths: isinstance(search_paths, tuple),
377
+ "search_paths must be a tuple",
378
+ )
379
+ @icontract.ensure(lambda result: isinstance(result, dict), "result must be a dict")
380
+ def _run_tests_with_coverage(
381
+ source_file: str,
382
+ search_paths: tuple[str, ...],
383
+ ) -> dict[str, Any]:
384
+ """Run tests in subprocess with coverage and return JSON data.
385
+
386
+ Args:
387
+ source_file: Absolute path to the source file being analyzed.
388
+ search_paths: Import roots for the project.
389
+
390
+ Returns:
391
+ Parsed coverage JSON data, or empty dict on failure.
392
+ """
393
+ # Find project root by walking up from source file
394
+ project_root = _find_project_root(source_file, search_paths)
395
+ if project_root is None:
396
+ return {}
397
+
398
+ # Find test directory
399
+ test_dir = _find_test_dir(project_root)
400
+
401
+ # Determine coverage scope: use the source root (search paths) to
402
+ # cover all project source in a single run, not just one file's dir.
403
+ cov_source = _find_source_root(project_root, search_paths)
404
+
405
+ with tempfile.TemporaryDirectory() as tmpdir:
406
+ json_file = os.path.join(tmpdir, "coverage.json")
407
+
408
+ cmd = [
409
+ sys.executable, "-m", "pytest",
410
+ "--no-header", "-q",
411
+ f"--cov={cov_source}",
412
+ "--cov-branch",
413
+ f"--cov-report=json:{json_file}",
414
+ "--cov-report=",
415
+ "--tb=no",
416
+ ]
417
+ if test_dir is not None:
418
+ cmd.append(test_dir)
419
+ else:
420
+ cmd.append(project_root)
421
+
422
+ env = dict(os.environ)
423
+ # Loop invariant: env PYTHONPATH includes all search_paths[0..i]
424
+ for sp in search_paths:
425
+ existing = env.get("PYTHONPATH", "")
426
+ # Split on os.pathsep to check exact path entries, avoiding
427
+ # false matches from substring containment (e.g., "/foo" in "/foo/bar").
428
+ existing_paths = set(existing.split(os.pathsep)) if existing else set()
429
+ if sp not in existing_paths:
430
+ env["PYTHONPATH"] = f"{sp}{os.pathsep}{existing}" if existing else sp
431
+
432
+ try:
433
+ proc = subprocess.run(
434
+ cmd,
435
+ capture_output=True,
436
+ text=True,
437
+ timeout=120,
438
+ cwd=project_root,
439
+ env=env,
440
+ )
441
+ except subprocess.TimeoutExpired:
442
+ return {"_error": "test execution timed out after 120 seconds"}
443
+ except FileNotFoundError:
444
+ return {"_error": "pytest not found — install pytest and pytest-cov"}
445
+ except OSError as os_err:
446
+ return {"_error": f"cannot run pytest: {os_err}"}
447
+
448
+ if not os.path.exists(json_file):
449
+ stderr_hint = (proc.stderr or "").strip()[:200]
450
+ return {"_error": f"pytest did not produce coverage data (exit {proc.returncode})"
451
+ + (f": {stderr_hint}" if stderr_hint else "")}
452
+
453
+ try:
454
+ with open(json_file, encoding="utf-8") as f:
455
+ return json.load(f) # type: ignore[no-any-return]
456
+ except json.JSONDecodeError as json_err:
457
+ return {"_error": f"coverage JSON is malformed: {json_err}"}
458
+ except OSError as read_err:
459
+ return {"_error": f"cannot read coverage JSON: {read_err}"}
460
+
461
+
462
+ @icontract.require(
463
+ lambda source_file: isinstance(source_file, str),
464
+ "source_file must be a string",
465
+ )
466
+ @icontract.require(
467
+ lambda search_paths: isinstance(search_paths, tuple),
468
+ "search_paths must be a tuple",
469
+ )
470
+ @icontract.ensure(
471
+ lambda result: result is None or isinstance(result, str),
472
+ "result must be a string or None",
473
+ )
474
+ def _find_project_root(
475
+ source_file: str,
476
+ search_paths: tuple[str, ...],
477
+ ) -> str | None:
478
+ """Find the project root directory.
479
+
480
+ Walks up from source_file looking for pyproject.toml or setup.py.
481
+ Falls back to the first search path.
482
+
483
+ Args:
484
+ source_file: Path to the source file.
485
+ search_paths: Import roots.
486
+
487
+ Returns:
488
+ Project root path, or None.
489
+ """
490
+ current = Path(source_file).parent
491
+ # Variant: depth decreases each iteration
492
+ for _ in range(20):
493
+ if (current / "pyproject.toml").exists() or (current / "setup.py").exists():
494
+ return str(current)
495
+ parent = current.parent
496
+ if parent == current:
497
+ break
498
+ current = parent
499
+
500
+ if search_paths:
501
+ return search_paths[0]
502
+ return None
503
+
504
+
505
+ @icontract.require(
506
+ lambda project_root: is_non_empty_string(project_root),
507
+ "project_root must be a non-empty string",
508
+ )
509
+ @icontract.ensure(
510
+ lambda result: result is None or isinstance(result, str),
511
+ "result must be a string or None",
512
+ )
513
+ def _find_test_dir(project_root: str) -> str | None:
514
+ """Find the test directory in a project.
515
+
516
+ Args:
517
+ project_root: Project root directory.
518
+
519
+ Returns:
520
+ Path to test directory, or None.
521
+ """
522
+ # Loop invariant: checked candidates[0..i] for existence
523
+ for candidate in ("tests", "test"):
524
+ test_path = os.path.join(project_root, candidate)
525
+ if os.path.isdir(test_path):
526
+ return test_path
527
+ return None
528
+
529
+
530
+ @icontract.require(
531
+ lambda project_root: is_non_empty_string(project_root),
532
+ "project_root must be a non-empty string",
533
+ )
534
+ @icontract.require(
535
+ lambda search_paths: isinstance(search_paths, tuple),
536
+ "search_paths must be a tuple",
537
+ )
538
+ @icontract.ensure(
539
+ lambda result: isinstance(result, str),
540
+ "result must be a non-empty string",
541
+ )
542
+ def _find_source_root(
543
+ project_root: str,
544
+ search_paths: tuple[str, ...],
545
+ ) -> str:
546
+ """Find the source root directory for coverage scoping.
547
+
548
+ Uses the first search path that exists, then falls back to
549
+ common source directory names, then the project root itself.
550
+
551
+ Args:
552
+ project_root: Project root directory.
553
+ search_paths: Import roots for the project.
554
+
555
+ Returns:
556
+ Path to use as the --cov source root.
557
+ """
558
+ # Prefer an explicit search path that exists
559
+ # Loop invariant: checked search_paths[0..i] for existence
560
+ for sp in search_paths:
561
+ if os.path.isdir(sp):
562
+ return sp
563
+
564
+ # Fall back to common source directory names
565
+ # Loop invariant: checked candidates[0..i] for existence
566
+ for candidate in ("src", "lib"):
567
+ src_path = os.path.join(project_root, candidate)
568
+ if os.path.isdir(src_path):
569
+ return src_path
570
+
571
+ return project_root
572
+
573
+
574
+ @icontract.require(
575
+ lambda coverage_data: isinstance(coverage_data, dict),
576
+ "coverage_data must be a dict",
577
+ )
578
+ @icontract.require(
579
+ lambda functions: isinstance(functions, list),
580
+ "functions must be a list",
581
+ )
582
+ @icontract.require(
583
+ lambda source_file: isinstance(source_file, str),
584
+ "source_file must be a string",
585
+ )
586
+ @icontract.ensure(lambda result: isinstance(result, list), "result must be a list")
587
+ def _map_coverage_to_functions(
588
+ coverage_data: dict[str, Any],
589
+ functions: list[_FunctionNode],
590
+ source_file: str,
591
+ ) -> list[_FunctionCoverage]:
592
+ """Map file-level coverage data to per-function metrics.
593
+
594
+ Args:
595
+ coverage_data: Parsed JSON from coverage.py report.
596
+ functions: AST-discovered functions.
597
+ source_file: Absolute path to the source file.
598
+
599
+ Returns:
600
+ List of per-function coverage metrics.
601
+ """
602
+ if not coverage_data:
603
+ # No coverage data — report 0% for all functions
604
+ results: list[_FunctionCoverage] = []
605
+ # Loop invariant: results contains zero-coverage entries for functions[0..i]
606
+ for func in functions:
607
+ total = max(1, func.line_end - func.line_start + 1)
608
+ all_lines = frozenset(range(func.line_start, func.line_end + 1))
609
+ results.append(_FunctionCoverage(
610
+ function=func,
611
+ total_lines=total,
612
+ executed_lines=frozenset(),
613
+ missing_lines=all_lines,
614
+ total_branches=0,
615
+ executed_branches=(),
616
+ missing_branches=(),
617
+ ))
618
+ return results
619
+
620
+ # Extract file data from coverage JSON.
621
+ # Coverage.py may store relative or absolute paths. We use os.path.samefile
622
+ # for robust matching that handles symlinks and case-insensitive filesystems,
623
+ # falling back to resolved string comparison if the file doesn't exist.
624
+ files_data = coverage_data.get("files", {})
625
+ file_info = None
626
+ # Loop invariant: file_info is set if any key in files_data matches source_file
627
+ for file_key, file_data in files_data.items():
628
+ try:
629
+ if os.path.exists(file_key) and os.path.exists(source_file):
630
+ if os.path.samefile(file_key, source_file):
631
+ file_info = file_data
632
+ break
633
+ else:
634
+ resolved_key = str(Path(file_key).resolve())
635
+ source_resolved = str(Path(source_file).resolve())
636
+ if resolved_key == source_resolved:
637
+ file_info = file_data
638
+ break
639
+ except (OSError, ValueError):
640
+ continue
641
+
642
+ if file_info is None:
643
+ # Source file not in coverage data — 0% coverage
644
+ results = []
645
+ # Loop invariant: results contains zero-coverage entries for functions[0..i]
646
+ for func in functions:
647
+ total = max(1, func.line_end - func.line_start + 1)
648
+ all_lines = frozenset(range(func.line_start, func.line_end + 1))
649
+ results.append(_FunctionCoverage(
650
+ function=func,
651
+ total_lines=total,
652
+ executed_lines=frozenset(),
653
+ missing_lines=all_lines,
654
+ total_branches=0,
655
+ executed_branches=(),
656
+ missing_branches=(),
657
+ ))
658
+ return results
659
+
660
+ executed_lines_set = frozenset(file_info.get("executed_lines", []))
661
+ missing_lines_set = frozenset(file_info.get("missing_lines", []))
662
+ executed_branches_raw: list[list[int]] = file_info.get("executed_branches", [])
663
+ missing_branches_raw: list[list[int]] = file_info.get("missing_branches", [])
664
+
665
+ executed_branches_all = tuple((b[0], b[1]) for b in executed_branches_raw if len(b) == 2)
666
+ missing_branches_all = tuple((b[0], b[1]) for b in missing_branches_raw if len(b) == 2)
667
+
668
+ results = []
669
+ # Loop invariant: results contains coverage for functions[0..i]
670
+ for func in functions:
671
+ func_range = range(func.line_start, func.line_end + 1)
672
+ func_executed = frozenset(l for l in executed_lines_set if l in func_range)
673
+ func_missing = frozenset(l for l in missing_lines_set if l in func_range)
674
+ # Use the union of executed + missing as the set of executable lines.
675
+ # This avoids double-counting if the sets overlap (they shouldn't, but
676
+ # be safe) and correctly excludes non-executable lines (blank, comments).
677
+ total = len(func_executed | func_missing)
678
+
679
+ func_exec_branches = tuple(b for b in executed_branches_all if b[0] in func_range)
680
+ func_miss_branches = tuple(b for b in missing_branches_all if b[0] in func_range)
681
+ total_branches = len(func_exec_branches) + len(func_miss_branches)
682
+
683
+ # When total is 0 (no executable lines — e.g., a stub function with
684
+ # only a docstring or pass), keep total_lines=0 so the caller can
685
+ # report 100% coverage rather than a misleading 0%.
686
+ results.append(_FunctionCoverage(
687
+ function=func,
688
+ total_lines=total,
689
+ executed_lines=func_executed,
690
+ missing_lines=func_missing,
691
+ total_branches=total_branches,
692
+ executed_branches=func_exec_branches,
693
+ missing_branches=func_miss_branches,
694
+ ))
695
+
696
+ return results
697
+
698
+
699
+ @icontract.require(
700
+ lambda source: isinstance(source, str),
701
+ "source must be a string",
702
+ )
703
+ @icontract.require(
704
+ lambda module_path: isinstance(module_path, str),
705
+ "module_path must be a string",
706
+ )
707
+ @icontract.ensure(lambda result: isinstance(result, tuple), "result must be a tuple")
708
+ def _generate_suggestions(
709
+ fc: _FunctionCoverage,
710
+ source: str,
711
+ module_path: str,
712
+ ) -> tuple[TestSuggestion, ...]:
713
+ """Generate test suggestions for uncovered code paths.
714
+
715
+ Args:
716
+ fc: Function coverage data.
717
+ source: Full module source code.
718
+ module_path: Module path for import references.
719
+
720
+ Returns:
721
+ Tuple of test suggestions.
722
+ """
723
+ lines = source.splitlines()
724
+ try:
725
+ tree = ast.parse(source)
726
+ except SyntaxError:
727
+ return ()
728
+
729
+ # Group contiguous uncovered lines into blocks
730
+ blocks = _group_contiguous_lines(sorted(fc.missing_lines))
731
+ suggestions: list[TestSuggestion] = []
732
+
733
+ # Loop invariant: suggestions contains entries for blocks[0..i]
734
+ for block in blocks:
735
+ # Find dependencies in uncovered lines
736
+ deps = _find_dependencies_in_lines(tree, block, source, module_path)
737
+
738
+ # Find the branch condition if this is inside an if/try/while
739
+ context = _describe_uncovered_block(lines, block)
740
+
741
+ # Generate test code
742
+ func_name = fc.function.name
743
+ class_name = fc.function.class_name
744
+ test_code = _generate_test_code(func_name, class_name, module_path, block, context, deps)
745
+
746
+ all_necessary = all(d.mock_necessary for d in deps) if deps else True
747
+
748
+ suggestions.append(TestSuggestion(
749
+ description=context,
750
+ target_lines=tuple(block),
751
+ suggested_test_code=test_code,
752
+ required_mocks=tuple(deps),
753
+ all_mocks_necessary=all_necessary,
754
+ ))
755
+
756
+ return tuple(suggestions)
757
+
758
+
759
+ @icontract.require(
760
+ lambda lines: isinstance(lines, list),
761
+ "lines must be a list",
762
+ )
763
+ @icontract.ensure(lambda result: isinstance(result, list), "result must be a list")
764
+ def _group_contiguous_lines(lines: list[int]) -> list[list[int]]:
765
+ """Group sorted line numbers into contiguous blocks.
766
+
767
+ Args:
768
+ lines: Sorted list of line numbers.
769
+
770
+ Returns:
771
+ List of contiguous line groups.
772
+ """
773
+ if not lines:
774
+ return []
775
+ blocks: list[list[int]] = [[lines[0]]]
776
+ # Loop invariant: blocks[-1] is the current contiguous group
777
+ for line in lines[1:]:
778
+ if line == blocks[-1][-1] + 1:
779
+ blocks[-1].append(line)
780
+ else:
781
+ blocks.append([line])
782
+ return blocks
783
+
784
+
785
+ @icontract.require(
786
+ lambda tree: isinstance(tree, ast.Module),
787
+ "tree must be an ast.Module",
788
+ )
789
+ @icontract.require(
790
+ lambda uncovered_lines: isinstance(uncovered_lines, list),
791
+ "uncovered_lines must be a list",
792
+ )
793
+ @icontract.require(
794
+ lambda source: isinstance(source, str),
795
+ "source must be a string",
796
+ )
797
+ @icontract.require(
798
+ lambda module_path: isinstance(module_path, str),
799
+ "module_path must be a string",
800
+ )
801
+ @icontract.ensure(lambda result: isinstance(result, list), "result must be a list")
802
+ def _find_dependencies_in_lines(
803
+ tree: ast.Module,
804
+ uncovered_lines: list[int],
805
+ source: str,
806
+ module_path: str,
807
+ ) -> list[MockDependency]:
808
+ """Find call dependencies in uncovered lines via AST analysis.
809
+
810
+ Args:
811
+ tree: Parsed AST module.
812
+ uncovered_lines: Line numbers to analyze.
813
+ source: Full source code.
814
+ module_path: Module path for classifying internal vs external.
815
+
816
+ Returns:
817
+ List of mock dependencies found.
818
+ """
819
+ line_set = frozenset(uncovered_lines)
820
+ deps: list[MockDependency] = []
821
+ seen_names: set[str] = set()
822
+
823
+ # Build import map for the module
824
+ imports = _build_import_map(tree)
825
+
826
+ # Walk AST looking for calls on uncovered lines
827
+ # Loop invariant: deps contains unique dependencies from nodes visited so far
828
+ for node in ast.walk(tree):
829
+ if not hasattr(node, "lineno") or node.lineno not in line_set:
830
+ continue
831
+ if not isinstance(node, ast.Call):
832
+ continue
833
+
834
+ call_name = _get_call_name(node)
835
+ if call_name is None or call_name in seen_names:
836
+ continue
837
+ seen_names.add(call_name)
838
+
839
+ # Classify the dependency
840
+ top_module = call_name.split(".")[0]
841
+ import_source = imports.get(top_module, top_module)
842
+ is_external = _is_external_dependency(import_source)
843
+ is_io = _is_io_call(call_name, import_source)
844
+
845
+ deps.append(MockDependency(
846
+ name=call_name,
847
+ import_module=import_source,
848
+ is_external=is_external,
849
+ mock_necessary=is_io,
850
+ reason=_classify_reason(import_source, is_external, is_io),
851
+ ))
852
+
853
+ return deps
854
+
855
+
856
+ @icontract.require(
857
+ lambda tree: isinstance(tree, ast.Module),
858
+ "tree must be an ast.Module",
859
+ )
860
+ @icontract.ensure(lambda result: isinstance(result, dict), "result must be a dict")
861
+ def _build_import_map(tree: ast.Module) -> dict[str, str]:
862
+ """Build a mapping from local names to their import sources.
863
+
864
+ Args:
865
+ tree: Parsed AST module.
866
+
867
+ Returns:
868
+ Dict mapping local name to source module.
869
+ """
870
+ imports: dict[str, str] = {}
871
+ # Walk the full AST to capture imports inside functions, try/except,
872
+ # and if TYPE_CHECKING blocks — not just top-level imports.
873
+ # Loop invariant: imports contains bindings from all import nodes visited so far
874
+ for node in ast.walk(tree):
875
+ if isinstance(node, ast.Import):
876
+ # Loop invariant: imports contains bindings for aliases[0..j]
877
+ for alias in node.names:
878
+ local = alias.asname if alias.asname else alias.name
879
+ imports[local] = alias.name
880
+ elif isinstance(node, ast.ImportFrom):
881
+ module = node.module or ""
882
+ # Loop invariant: imports contains bindings for aliases[0..j]
883
+ for alias in node.names:
884
+ local = alias.asname if alias.asname else alias.name
885
+ imports[local] = module
886
+ return imports
887
+
888
+
889
+ @icontract.require(
890
+ lambda node: isinstance(node, ast.Call),
891
+ "node must be an ast.Call",
892
+ )
893
+ @icontract.ensure(
894
+ lambda result: result is None or isinstance(result, str),
895
+ "result must be a string or None",
896
+ )
897
+ def _get_call_name(node: ast.Call) -> str | None:
898
+ """Extract the call target name from an AST Call node.
899
+
900
+ Args:
901
+ node: An AST Call node.
902
+
903
+ Returns:
904
+ Dotted name string, or None if too complex.
905
+ """
906
+ func = node.func
907
+ if isinstance(func, ast.Name):
908
+ return func.id
909
+ if isinstance(func, ast.Attribute):
910
+ parts: list[str] = [func.attr]
911
+ current: ast.expr = func.value
912
+ # Variant: nesting depth decreases
913
+ while isinstance(current, ast.Attribute):
914
+ parts.append(current.attr)
915
+ current = current.value
916
+ if isinstance(current, ast.Name):
917
+ parts.append(current.id)
918
+ return ".".join(reversed(parts))
919
+ return None
920
+
921
+
922
+ @icontract.require(
923
+ lambda import_source: isinstance(import_source, str),
924
+ "import_source must be a string",
925
+ )
926
+ @icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
927
+ def _is_external_dependency(import_source: str) -> bool:
928
+ """Check if an import source is an external package.
929
+
930
+ Args:
931
+ import_source: The source module name.
932
+
933
+ Returns:
934
+ True if external (not stdlib, not project-internal).
935
+ """
936
+ top = import_source.split(".")[0]
937
+ return top in _IO_MODULES or top in {
938
+ "celery", "django", "flask", "fastapi", "starlette",
939
+ "pydantic", "motor", "pymongo",
940
+ }
941
+
942
+
943
+ @icontract.require(
944
+ lambda call_name: isinstance(call_name, str),
945
+ "call_name must be a string",
946
+ )
947
+ @icontract.require(
948
+ lambda import_source: isinstance(import_source, str),
949
+ "import_source must be a string",
950
+ )
951
+ @icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
952
+ def _is_io_call(call_name: str, import_source: str) -> bool:
953
+ """Check if a call involves I/O that should be mocked.
954
+
955
+ Args:
956
+ call_name: The full call name.
957
+ import_source: The source module.
958
+
959
+ Returns:
960
+ True if this is an I/O call requiring a mock.
961
+ """
962
+ top = import_source.split(".")[0]
963
+ if top in _IO_MODULES:
964
+ return True
965
+ # Check for known I/O patterns in the call name
966
+ final_name = call_name.rsplit(".", 1)[-1]
967
+ return final_name in _IO_CALL_PATTERNS
968
+
969
+
970
+ @icontract.require(
971
+ lambda import_source: isinstance(import_source, str),
972
+ "import_source must be a string",
973
+ )
974
+ @icontract.require(
975
+ lambda is_external: isinstance(is_external, bool),
976
+ "is_external must be a bool",
977
+ )
978
+ @icontract.require(
979
+ lambda is_io: isinstance(is_io, bool),
980
+ "is_io must be a bool",
981
+ )
982
+ @icontract.ensure(
983
+ lambda result: is_non_empty_string(result),
984
+ "result must be a non-empty string",
985
+ )
986
+ def _classify_reason(import_source: str, is_external: bool, is_io: bool) -> str:
987
+ """Produce a human-readable reason for the mock classification.
988
+
989
+ Args:
990
+ import_source: The source module.
991
+ is_external: Whether it's external.
992
+ is_io: Whether it's I/O.
993
+
994
+ Returns:
995
+ Reason string.
996
+ """
997
+ if is_io:
998
+ top = import_source.split(".")[0]
999
+ if top in {"os", "pathlib", "shutil", "tempfile", "glob"}:
1000
+ return "file system I/O"
1001
+ if top in {"subprocess"}:
1002
+ return "subprocess execution"
1003
+ if top in {"requests", "http", "urllib", "aiohttp", "httpx"}:
1004
+ return "network I/O"
1005
+ if top in {"socket", "smtplib", "ftplib", "paramiko"}:
1006
+ return "network I/O"
1007
+ if top in {"sqlite3", "sqlalchemy", "redis", "pymongo", "motor"}:
1008
+ return "database I/O"
1009
+ if top in {"boto3"}:
1010
+ return "cloud API"
1011
+ return "external I/O"
1012
+ if is_external:
1013
+ return "external library"
1014
+ return "internal code — can use real implementation"
1015
+
1016
+
1017
+ @icontract.require(
1018
+ lambda lines: isinstance(lines, list),
1019
+ "lines must be a list",
1020
+ )
1021
+ @icontract.require(
1022
+ lambda block: isinstance(block, list),
1023
+ "block must be a list",
1024
+ )
1025
+ @icontract.ensure(lambda result: isinstance(result, str), "result must be a string")
1026
+ def _describe_uncovered_block(lines: list[str], block: list[int]) -> str:
1027
+ """Describe what an uncovered block of code does.
1028
+
1029
+ Args:
1030
+ lines: All source lines (0-indexed).
1031
+ block: 1-indexed line numbers of the uncovered block.
1032
+
1033
+ Returns:
1034
+ Human-readable description.
1035
+ """
1036
+ if not block:
1037
+ return "uncovered code"
1038
+
1039
+ first_line_idx = block[0] - 1
1040
+ if first_line_idx < 0 or first_line_idx >= len(lines):
1041
+ return f"lines {block[0]}-{block[-1]}"
1042
+
1043
+ first_line = lines[first_line_idx].strip()
1044
+
1045
+ # Check lines BEFORE the block for branch context, scanning upward
1046
+ # past comments and blank lines to find the controlling statement.
1047
+ # Variant: scan_idx decreases each iteration, bounded by 0
1048
+ scan_idx = first_line_idx - 1
1049
+ scan_limit = max(0, first_line_idx - 5)
1050
+ while scan_idx >= scan_limit:
1051
+ prev_line = lines[scan_idx].strip()
1052
+ # Skip blank lines and comments
1053
+ if not prev_line or prev_line.startswith("#"):
1054
+ scan_idx -= 1
1055
+ continue
1056
+ if prev_line.startswith("if ") or prev_line.startswith("elif "):
1057
+ condition = prev_line.rstrip(":").strip()
1058
+ return f"branch: {condition} (lines {block[0]}-{block[-1]})"
1059
+ if prev_line.startswith("except"):
1060
+ return f"exception handler: {prev_line.rstrip(':')} (lines {block[0]}-{block[-1]})"
1061
+ if prev_line.startswith("else"):
1062
+ return f"else branch (lines {block[0]}-{block[-1]})"
1063
+ break
1064
+
1065
+ if first_line.startswith("if ") or first_line.startswith("elif "):
1066
+ condition = first_line.rstrip(":").strip()
1067
+ return f"branch: {condition} (lines {block[0]}-{block[-1]})"
1068
+ if first_line.startswith("except"):
1069
+ return f"exception handler: {first_line.rstrip(':')} (lines {block[0]}-{block[-1]})"
1070
+ if first_line.startswith("raise"):
1071
+ return f"error path: {first_line} (line {block[0]})"
1072
+ if first_line.startswith("return"):
1073
+ return f"return path (line {block[0]})"
1074
+
1075
+ return f"lines {block[0]}-{block[-1]}"
1076
+
1077
+
1078
+ @icontract.require(
1079
+ lambda func_name: is_non_empty_string(func_name),
1080
+ "func_name must be a non-empty string",
1081
+ )
1082
+ @icontract.require(
1083
+ lambda module_path: isinstance(module_path, str),
1084
+ "module_path must be a string",
1085
+ )
1086
+ @icontract.require(
1087
+ lambda block: isinstance(block, list),
1088
+ "block must be a list",
1089
+ )
1090
+ @icontract.require(
1091
+ lambda context: isinstance(context, str),
1092
+ "context must be a string",
1093
+ )
1094
+ @icontract.require(
1095
+ lambda deps: isinstance(deps, list),
1096
+ "deps must be a list",
1097
+ )
1098
+ @icontract.ensure(lambda result: isinstance(result, str), "result must be a string")
1099
+ def _generate_test_code(
1100
+ func_name: str,
1101
+ class_name: str | None,
1102
+ module_path: str,
1103
+ block: list[int],
1104
+ context: str,
1105
+ deps: list[MockDependency],
1106
+ ) -> str:
1107
+ """Generate a pytest test function for an uncovered block.
1108
+
1109
+ Args:
1110
+ func_name: Name of the function being tested.
1111
+ class_name: Class name if it's a method, None otherwise.
1112
+ module_path: Module path for imports.
1113
+ block: Uncovered line numbers.
1114
+ context: Description of what the block does.
1115
+ deps: Dependencies that need mocking.
1116
+
1117
+ Returns:
1118
+ A complete test function as a string.
1119
+ """
1120
+ test_name = func_name.lstrip("_")
1121
+ line_range = f"{block[0]}-{block[-1]}" if len(block) > 1 else str(block[0])
1122
+
1123
+ parts: list[str] = []
1124
+
1125
+ # Import statement
1126
+ if class_name:
1127
+ parts.append(f"from {module_path} import {class_name}")
1128
+ else:
1129
+ parts.append(f"from {module_path} import {func_name}")
1130
+
1131
+ # Mock imports
1132
+ if any(d.mock_necessary for d in deps):
1133
+ parts.append("from unittest.mock import patch, MagicMock")
1134
+
1135
+ parts.append("")
1136
+
1137
+ # Build decorator stack for mocks.
1138
+ # Patch at the usage site (module_path), not the definition site (dep.import_module).
1139
+ # See https://docs.python.org/3/library/unittest.mock.html#where-to-patch
1140
+ mock_decorators: list[str] = []
1141
+ mock_params: list[str] = []
1142
+ # Loop invariant: mock_decorators and mock_params account for deps[0..i] that need mocking
1143
+ for dep in deps:
1144
+ if dep.mock_necessary:
1145
+ mock_var = f"mock_{dep.name.replace('.', '_')}"
1146
+ mock_decorators.append(f"@patch('{module_path}.{dep.name.split('.')[-1]}')")
1147
+ mock_params.append(mock_var)
1148
+
1149
+ # Function signature
1150
+ # Loop invariant: decorator lines added for mock_decorators[0..i]
1151
+ for dec in mock_decorators:
1152
+ parts.append(dec)
1153
+
1154
+ params = ", ".join(mock_params) if mock_params else ""
1155
+ parts.append(f"def test_{test_name}_line_{block[0]}({params}):")
1156
+ parts.append(f' """Cover {context}."""')
1157
+
1158
+ # Setup mocks
1159
+ # Loop invariant: mock setup lines added for mock_params[0..i]
1160
+ for i, dep in enumerate(deps):
1161
+ if dep.mock_necessary and i < len(mock_params):
1162
+ parts.append(f" {mock_params[i]}.return_value = None # TODO: configure mock return value")
1163
+
1164
+ # Call
1165
+ if class_name:
1166
+ parts.append(f" instance = {class_name}() # TODO: provide constructor args")
1167
+ parts.append(f" result = instance.{func_name}() # TODO: provide args to reach line {block[0]}")
1168
+ else:
1169
+ parts.append(f" result = {func_name}() # TODO: provide args to reach line {block[0]}")
1170
+
1171
+ parts.append(f" assert result is not None # replace with specific assertion")
1172
+
1173
+ return "\n".join(parts)