Coverage for sygaldry / checker.py: 66%
107 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-01 00:06 -0400
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-01 00:06 -0400
1from __future__ import annotations
3__author__ = "Rohan B. Dalton"
5import json
6import os
7import re
8import shutil
9import subprocess
10import tempfile
11from dataclasses import dataclass
12from pathlib import Path
13from typing import Any, Optional
15from .codegen import SourceMapping, generate_check_source
16from .loader import load_config
18_MYPY_PATTERN = re.compile(
19 r"^.+?:(?P<line>\d+):\d+:\s*(?P<severity>error|warning|note):\s*(?P<message>.+?)(?:\s+\[.+\])?$"
20)
21_TY_LINE_PATTERN = re.compile(r"^\s*-->\s*.+?:(?P<line>\d+):\d+")
22_TY_DIAG_PATTERN = re.compile(r"^(?P<severity>error|warning)\[.+?\]:\s*(?P<message>.+)")
23SUPPORTED_CHECKERS = ("ty", "basedpyright", "pyright", "mypy")
26@dataclass(frozen=True)
27class CheckError:
28 """
29 A type error found in a config file.
30 """
32 config_path: str
33 message: str
34 severity: str = "error"
37def check(
38 path: str | Path | None = None,
39 *,
40 type_checker: str | None = None,
41 config: dict[str, Any] | None = None,
42) -> list[CheckError]:
43 """
44 Type check a sygaldry config file.
46 Either *path* or *config* must be provided. When *config* is given
47 the config dict is used directly (useful from the CLI after loading
48 and applying overrides). When *path* is given, the file is loaded
49 via :func:`load_config`.
51 :param path: Path to a YAML/TOML config file.
52 :param type_checker: Which checker to run (``ty``, ``basedpyright``,
53 ``pyright``, or ``mypy``). Auto-detected when *None*.
54 :param config: Pre-loaded config dict.
55 :returns: List of type errors found.
56 """
57 if config is None:
58 if path is None:
59 raise ValueError("Either path or config must be provided.")
60 else:
61 config = load_config(Path(path))
63 source, mappings = generate_check_source(config)
65 checker = type_checker or _detect_type_checker()
66 return _run_and_parse(checker, source, mappings)
69def _detect_type_checker() -> str:
70 """
71 Auto-detect an available type checker.
72 """
73 for name in SUPPORTED_CHECKERS: 73 ↛ 77line 73 didn't jump to line 77 because the loop on line 73 didn't complete
74 if shutil.which(name): 74 ↛ 73line 74 didn't jump to line 73 because the condition on line 74 was always true
75 return name
76 else:
77 raise RuntimeError(
78 "No supported type checker found. Install ty, basedpyright, pyright, or mypy."
79 )
82def _run_and_parse(
83 checker: str,
84 source: str,
85 mappings: list[SourceMapping],
86) -> list[CheckError]:
87 """
88 Write source to a temp file, run the checker, and parse results.
89 """
90 fd, tmp_path = tempfile.mkstemp(suffix=".py", prefix="_sygaldry_check_")
91 with os.fdopen(fd, "w") as fh:
92 fh.write(source)
93 result = _invoke_checker(checker, tmp_path)
94 os.unlink(tmp_path)
95 return _parse_output(checker, result, mappings)
98def _invoke_checker(checker: str, filepath: str) -> subprocess.CompletedProcess:
99 """
100 Run the type checker subprocess.
101 """
102 if checker in {"pyright", "basedpyright"}: 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true
103 cmd = [checker, "--outputjson", filepath]
104 elif checker == "mypy": 104 ↛ 105line 104 didn't jump to line 105 because the condition on line 104 was never true
105 cmd = ["mypy", "--no-color-output", "--show-column-numbers", filepath]
106 elif checker == "ty": 106 ↛ 109line 106 didn't jump to line 109 because the condition on line 106 was always true
107 cmd = ["ty", "check", filepath]
108 else:
109 raise ValueError(f"Unsupported type checker: {checker}")
111 return subprocess.run(
112 cmd,
113 capture_output=True,
114 text=True,
115 timeout=120,
116 )
119def _parse_output(
120 checker: str,
121 result: subprocess.CompletedProcess,
122 mappings: list[SourceMapping],
123) -> list[CheckError]:
124 """
125 Parse type checker output into CheckError instances.
126 """
127 if checker in ("pyright", "basedpyright"): 127 ↛ 128line 127 didn't jump to line 128 because the condition on line 127 was never true
128 return _parse_pyright(result, mappings)
129 elif checker == "mypy": 129 ↛ 130line 129 didn't jump to line 130 because the condition on line 129 was never true
130 return _parse_mypy(result, mappings)
131 elif checker == "ty": 131 ↛ 133line 131 didn't jump to line 133 because the condition on line 131 was always true
132 return _parse_ty(result, mappings)
133 return list()
136def _line_to_config_path(line: int, mappings: list[SourceMapping]) -> str:
137 """
138 Map a generated line number to its config path.
139 """
140 best: SourceMapping | None = None
141 for mapping in mappings:
142 if best is None or best.line < mapping.line <= line: 142 ↛ 141line 142 didn't jump to line 141 because the condition on line 142 was always true
143 best = mapping
144 return best.config_path if best else "<unknown>"
147def _parse_pyright(
148 result: subprocess.CompletedProcess,
149 mappings: list[SourceMapping],
150) -> list[CheckError]:
151 errors: list[CheckError] = list()
152 try:
153 data = json.loads(result.stdout)
154 except (json.JSONDecodeError, ValueError):
155 return errors
157 for diag in data.get("generalDiagnostics", list()):
158 line = diag.get("range", {}).get("start", {}).get("line", 0)
159 # pyright uses 0-based lines
160 config_path = _line_to_config_path(line + 1, mappings)
161 severity = diag.get("severity", "error")
162 message = diag.get("message", "")
163 errors.append(
164 CheckError(
165 config_path=config_path,
166 message=message,
167 severity=severity,
168 )
169 )
170 return errors
173def _parse_mypy(
174 result: subprocess.CompletedProcess,
175 mappings: list[SourceMapping],
176) -> list[CheckError]:
177 errors: list[CheckError] = list()
178 for raw_line in result.stdout.splitlines():
179 if match := _MYPY_PATTERN.match(raw_line):
180 line_no = int(match.group("line"))
181 severity = match.group("severity")
182 message = match.group("message")
183 if severity == "note":
184 continue
185 config_path = _line_to_config_path(line_no, mappings)
186 errors.append(
187 CheckError(
188 config_path=config_path,
189 message=message,
190 severity=severity,
191 )
192 )
193 return errors
196def _parse_ty(
197 result: subprocess.CompletedProcess,
198 mappings: list[SourceMapping],
199) -> list[CheckError]:
200 errors: list[CheckError] = list()
201 output = result.stdout + result.stderr
202 lines = output.splitlines()
204 for idx, line in enumerate(lines):
205 if diag_match := _TY_DIAG_PATTERN.match(line):
206 severity = diag_match.group("severity")
207 message = diag_match.group("message")
208 line_no = _extract_ty_line(lines, idx)
209 config_path = _line_to_config_path(line_no, mappings) if line_no else "<unknown>"
210 errors.append(
211 CheckError(
212 config_path=config_path,
213 message=message,
214 severity=severity,
215 )
216 )
218 return errors
221def _extract_ty_line(lines: list[str], diag_idx: int) -> int | None:
222 """
223 Look ahead from a ty diagnostic line to find the --> line number.
224 """
225 for idx in range(diag_idx + 1, min(diag_idx + 6, len(lines))): 225 ↛ 228line 225 didn't jump to line 228 because the loop on line 225 didn't complete
226 if match := _TY_LINE_PATTERN.match(lines[idx]): 226 ↛ 225line 226 didn't jump to line 225 because the condition on line 226 was always true
227 return int(match.group("line"))
228 return None