Coverage for lintro / tools / definitions / mypy.py: 76%

182 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Mypy tool definition. 

2 

3Mypy is a static type checker for Python that helps catch type-related 

4bugs before runtime. It uses type annotations (PEP 484) to verify that 

5your code is type-safe. 

6""" 

7 

8from __future__ import annotations 

9 

10import fnmatch 

11import subprocess # nosec B404 - used safely with shell disabled 

12from dataclasses import dataclass 

13from pathlib import Path 

14from typing import Any 

15 

16from loguru import logger 

17 

18from lintro.enums.doc_url_template import DocUrlTemplate 

19from lintro.enums.tool_type import ToolType 

20from lintro.models.core.tool_result import ToolResult 

21from lintro.parsers.mypy.mypy_parser import parse_mypy_output 

22from lintro.plugins.base import BaseToolPlugin 

23from lintro.plugins.protocol import ToolDefinition 

24from lintro.plugins.registry import register_tool 

25from lintro.tools.core.timeout_utils import create_timeout_result 

26from lintro.utils.config import load_mypy_config 

27 

28# Constants for Mypy configuration 

29MYPY_DEFAULT_TIMEOUT: int = 60 

30MYPY_DEFAULT_PRIORITY: int = 82 

31MYPY_FILE_PATTERNS: list[str] = ["*.py", "*.pyi"] 

32 

33MYPY_DEFAULT_EXCLUDE_PATTERNS: list[str] = [ 

34 "test_samples/*", 

35 "test_samples/**", 

36 "*/test_samples/*", 

37 "*/test_samples/**", 

38 "node_modules/**", 

39 "dist/**", 

40 "build/**", 

41] 

42 

43 

44def _split_config_values(raw_value: str) -> list[str]: 

45 """Split config strings that may be comma or newline separated. 

46 

47 Args: 

48 raw_value: Raw string from configuration that may contain commas or 

49 newlines. 

50 

51 Returns: 

52 list[str]: Individual, stripped config entries. 

53 """ 

54 entries: list[str] = [] 

55 for part in raw_value.replace("\n", ",").split(","): 

56 value = part.strip() 

57 if value: 

58 entries.append(value) 

59 return entries 

60 

61 

62def _regex_to_glob(pattern: str) -> str: 

63 """Coerce a simple regex pattern to a fnmatch glob. 

64 

65 Args: 

66 pattern: Regex-style pattern to coerce. 

67 

68 Returns: 

69 str: A best-effort fnmatch-style glob pattern. 

70 """ 

71 cleaned = pattern.strip() 

72 if cleaned.startswith("^"): 

73 cleaned = cleaned[1:] 

74 if cleaned.endswith("$"): 

75 cleaned = cleaned[:-1] 

76 cleaned = cleaned.replace(".*", "*") 

77 if cleaned.endswith("/"): 

78 cleaned = f"{cleaned}**" 

79 return cleaned 

80 

81 

82@register_tool 

83@dataclass 

84class MypyPlugin(BaseToolPlugin): 

85 """Mypy static type checker plugin. 

86 

87 This plugin integrates Mypy with Lintro for static type checking 

88 of Python files. 

89 """ 

90 

91 # Internal state for config 

92 _config_data: dict[str, Any] | None = None 

93 _config_path: Path | None = None 

94 

95 @property 

96 def definition(self) -> ToolDefinition: 

97 """Return the tool definition. 

98 

99 Returns: 

100 ToolDefinition containing tool metadata. 

101 """ 

102 return ToolDefinition( 

103 name="mypy", 

104 description="Static type checker for Python", 

105 can_fix=False, 

106 tool_type=ToolType.LINTER | ToolType.TYPE_CHECKER, 

107 file_patterns=MYPY_FILE_PATTERNS, 

108 priority=MYPY_DEFAULT_PRIORITY, 

109 conflicts_with=[], 

110 native_configs=["mypy.ini", ".mypy.ini", "pyproject.toml", "setup.cfg"], 

111 version_command=["mypy", "--version"], 

112 min_version="1.0.0", 

113 default_options={ 

114 "timeout": MYPY_DEFAULT_TIMEOUT, 

115 "strict": True, 

116 "ignore_missing_imports": True, 

117 "python_version": None, 

118 "config_file": None, 

119 "cache_dir": None, 

120 }, 

121 default_timeout=MYPY_DEFAULT_TIMEOUT, 

122 ) 

123 

124 def set_options( 

125 self, 

126 strict: bool | None = None, 

127 ignore_missing_imports: bool | None = None, 

128 python_version: str | None = None, 

129 config_file: str | None = None, 

130 cache_dir: str | None = None, 

131 **kwargs: Any, 

132 ) -> None: 

133 """Set Mypy-specific options. 

134 

135 Args: 

136 strict: Enable strict mode for more rigorous type checking. 

137 ignore_missing_imports: Ignore missing imports. 

138 python_version: Python version target (e.g., "3.10"). 

139 config_file: Path to mypy config file. 

140 cache_dir: Path to mypy cache directory. 

141 **kwargs: Other tool options. 

142 

143 Raises: 

144 ValueError: If any provided option is of an unexpected type. 

145 """ 

146 if strict is not None and not isinstance(strict, bool): 

147 raise ValueError("strict must be a boolean") 

148 if ignore_missing_imports is not None and not isinstance( 

149 ignore_missing_imports, 

150 bool, 

151 ): 

152 raise ValueError("ignore_missing_imports must be a boolean") 

153 if python_version is not None and not isinstance(python_version, str): 

154 raise ValueError("python_version must be a string") 

155 if config_file is not None and not isinstance(config_file, str): 

156 raise ValueError("config_file must be a string path") 

157 if cache_dir is not None and not isinstance(cache_dir, str): 

158 raise ValueError("cache_dir must be a string path") 

159 

160 options: dict[str, object] = { 

161 "strict": strict, 

162 "ignore_missing_imports": ignore_missing_imports, 

163 "python_version": python_version, 

164 "config_file": config_file, 

165 "cache_dir": cache_dir, 

166 } 

167 options = {k: v for k, v in options.items() if v is not None} 

168 super().set_options(**options, **kwargs) 

169 

170 def _glob_to_regex(self, pattern: str) -> str: 

171 """Convert a glob pattern to a mypy --exclude regex pattern. 

172 

173 Args: 

174 pattern: Glob-style pattern (e.g., "test_samples/**", "*.pyc"). 

175 

176 Returns: 

177 Regex pattern suitable for mypy --exclude. 

178 """ 

179 # Convert glob to regex and escape special chars except glob wildcards 

180 regex = fnmatch.translate(pattern) 

181 # Remove anchors added by fnmatch.translate (\\Z or \\z at end, ^ at start) 

182 # Python 3.14+ changed fnmatch.translate to use \\z instead of \\Z 

183 if regex.endswith("\\Z") or regex.endswith("\\z"): 

184 regex = regex[:-2] 

185 if regex.endswith("$"): 

186 regex = regex[:-1] 

187 if regex.startswith("^"): 

188 regex = regex[1:] 

189 if regex.startswith("(?s:"): 

190 regex = regex[4:] 

191 if regex.endswith(")"): 

192 regex = regex[:-1] 

193 

194 # Anchor directory segments to path boundaries to avoid matching substrings 

195 # For patterns starting with a literal directory (e.g., "dist/**"), prefix 

196 # with (?:^|/) so it matches only whole directory components 

197 if pattern and not pattern.startswith("*"): 

198 # Pattern starts with a literal - anchor to path boundary 

199 regex = f"(?:^|/){regex}" 

200 

201 return regex 

202 

203 def _build_command( 

204 self, 

205 files: list[str], 

206 *, 

207 excludes: list[str] | None = None, 

208 exclude_regexes: list[str] | None = None, 

209 ) -> list[str]: 

210 """Build the mypy invocation command. 

211 

212 Args: 

213 files: Relative file paths that should be checked by mypy. 

214 excludes: Optional list of glob patterns to convert to --exclude args. 

215 exclude_regexes: Optional list of raw regex patterns to pass directly 

216 to --exclude (preserves original config patterns without conversion). 

217 

218 Returns: 

219 A list of command arguments ready to be executed. 

220 """ 

221 cmd: list[str] = self._get_executable_command(tool_name="mypy") 

222 config_args = self._build_config_args() 

223 enforced = self._get_enforced_settings() 

224 cmd.extend( 

225 [ 

226 "--output", 

227 "json", 

228 "--show-error-codes", 

229 "--show-column-numbers", 

230 "--hide-error-context", 

231 "--no-error-summary", 

232 "--explicit-package-bases", 

233 ], 

234 ) 

235 

236 if config_args: 

237 cmd.extend(config_args) 

238 

239 if self.options.get("strict") is True: 

240 cmd.append("--strict") 

241 if self.options.get("ignore_missing_imports", True): 

242 cmd.append("--ignore-missing-imports") 

243 

244 if self.options.get("python_version") and "target_python" not in enforced: 

245 cmd.extend(["--python-version", str(self.options["python_version"])]) 

246 if self.options.get("config_file") and "--config-file" not in config_args: 

247 cmd.extend(["--config-file", str(self.options["config_file"])]) 

248 if self.options.get("cache_dir"): 

249 cmd.extend(["--cache-dir", str(self.options["cache_dir"])]) 

250 

251 # Add raw regex excludes first (from config, preserves fidelity) 

252 if exclude_regexes: 

253 for regex in exclude_regexes: 

254 stripped = regex.strip() 

255 if stripped: 

256 cmd.extend(["--exclude", stripped]) 

257 

258 # Add glob-based excludes (converted to regex for lintro defaults) 

259 if excludes: 

260 for pattern in excludes: 

261 regex = self._glob_to_regex(pattern) 

262 if regex: 

263 cmd.extend(["--exclude", regex]) 

264 

265 cmd.extend(files) 

266 return cmd 

267 

268 def _build_effective_excludes(self, configured_excludes: Any) -> list[str]: 

269 """Build effective exclude patterns from config and defaults. 

270 

271 Always includes default patterns, then adds any configured excludes. 

272 This ensures common directories (tests/, build/, dist/) are always 

273 excluded unless explicitly overridden. 

274 

275 Args: 

276 configured_excludes: Exclude patterns from mypy config. 

277 

278 Returns: 

279 list[str]: Combined exclude patterns. 

280 """ 

281 effective_excludes: list[str] = list(self.exclude_patterns) 

282 

283 # Always add default patterns first 

284 for default_pattern in MYPY_DEFAULT_EXCLUDE_PATTERNS: 

285 if default_pattern not in effective_excludes: 

286 effective_excludes.append(default_pattern) 

287 

288 # Then add configured excludes (if any) 

289 if configured_excludes: 

290 raw_excludes = ( 

291 [configured_excludes] 

292 if isinstance(configured_excludes, str) 

293 else list(configured_excludes) 

294 ) 

295 for pattern in raw_excludes: 

296 glob_pattern = _regex_to_glob(str(pattern)) 

297 if glob_pattern and glob_pattern not in effective_excludes: 

298 effective_excludes.append(glob_pattern) 

299 

300 return effective_excludes 

301 

302 def doc_url(self, code: str) -> str | None: 

303 """Return mypy documentation URL for the given error code. 

304 

305 Returns the error code list page. Individual anchor mapping is not 

306 reliable because mypy docs don't use the raw code as fragment IDs. 

307 

308 Args: 

309 code: mypy error code (e.g., "import-untyped"). 

310 

311 Returns: 

312 URL to the mypy error codes page, or None if code is empty. 

313 """ 

314 if code: 

315 return DocUrlTemplate.MYPY 

316 return None 

317 

318 def check(self, paths: list[str], options: dict[str, object]) -> ToolResult: 

319 """Check files with Mypy. 

320 

321 Args: 

322 paths: List of file or directory paths to check. 

323 options: Runtime options that override defaults. 

324 

325 Returns: 

326 ToolResult with check results. 

327 """ 

328 # Merge runtime options 

329 merged_options = dict(self.options) 

330 merged_options.update(options) 

331 

332 # Load mypy config first (needed to determine paths and excludes) 

333 base_dir = Path.cwd() 

334 config_data, config_path = load_mypy_config(base_dir=base_dir) 

335 self._config_data, self._config_path = config_data, config_path 

336 if config_path: 

337 logger.debug("Discovered mypy config at {}", config_path) 

338 

339 # Determine target paths (use config files if paths empty) 

340 target_paths: list[str] = list(paths) if paths else [] 

341 configured_files = config_data.get("files") 

342 if (not target_paths or target_paths == ["."]) and configured_files: 

343 if isinstance(configured_files, str): 

344 target_paths = [configured_files] 

345 elif isinstance(configured_files, list): 

346 target_paths = [ 

347 str(path) for path in configured_files if str(path).strip() 

348 ] 

349 

350 # Build effective excludes from config 

351 configured_excludes = config_data.get("exclude") 

352 effective_excludes = self._build_effective_excludes(configured_excludes) 

353 logger.debug("Effective mypy exclude patterns: {}", effective_excludes) 

354 

355 # Preserve raw config regexes for direct passing to mypy CLI 

356 configured_exclude_regexes: list[str] = [] 

357 if configured_excludes: 

358 raw_excludes = ( 

359 [configured_excludes] 

360 if isinstance(configured_excludes, str) 

361 else list(configured_excludes) 

362 ) 

363 configured_exclude_regexes = [ 

364 str(x).strip() for x in raw_excludes if str(x).strip() 

365 ] 

366 

367 # Temporarily update exclude patterns for file discovery 

368 original_excludes = self.exclude_patterns 

369 self.exclude_patterns = effective_excludes 

370 

371 # Use shared preparation with custom excludes 

372 ctx = self._prepare_execution( 

373 target_paths, 

374 merged_options, 

375 no_files_message="No files to check.", 

376 ) 

377 

378 # Restore original exclude patterns 

379 self.exclude_patterns = original_excludes 

380 

381 if ctx.should_skip and ctx.early_result is not None: 

382 return ctx.early_result 

383 

384 # Safety check: if should_skip but no early_result, create one 

385 if ctx.should_skip: 

386 return ToolResult( 

387 name=self.definition.name, 

388 success=True, 

389 output="No files to check.", 

390 issues_count=0, 

391 ) 

392 

393 logger.debug("[mypy] Discovered {} python file(s)", len(ctx.files)) 

394 

395 # Set config file if discovered 

396 if not self.options.get("config_file") and config_path: 

397 self.options["config_file"] = str(config_path.resolve()) 

398 logger.debug( 

399 "Setting mypy --config-file to {}", 

400 self.options["config_file"], 

401 ) 

402 

403 # Mypy needs to run from the project root to properly resolve module 

404 # structure. When checking a package directory, passing individual files 

405 # causes "Duplicate module named __main__" and type resolution errors. 

406 # Instead, we run from the original directory and pass the target paths. 

407 use_project_root = any(Path(p).is_dir() for p in target_paths) 

408 

409 effective_cwd: str | None 

410 mypy_excludes: list[str] | None = None 

411 mypy_exclude_regexes: list[str] | None = None 

412 if use_project_root: 

413 # Run mypy from where user invoked lintro, pass original paths 

414 # Since we're passing directories, we need to pass excludes to mypy 

415 # so it respects lintro's file filtering 

416 effective_cwd = str(Path.cwd()) 

417 mypy_targets = target_paths 

418 # Pass raw config regexes directly (preserves fidelity) 

419 # Only pass lintro defaults as globs (not config-derived ones) 

420 mypy_exclude_regexes = configured_exclude_regexes or None 

421 # Deduplicate excludes while preserving order 

422 mypy_excludes = list( 

423 dict.fromkeys( 

424 list(self.exclude_patterns) + list(MYPY_DEFAULT_EXCLUDE_PATTERNS), 

425 ), 

426 ) 

427 else: 

428 # For individual files, use the computed cwd and relative paths 

429 effective_cwd = ctx.cwd 

430 mypy_targets = ctx.rel_files if ctx.rel_files else target_paths 

431 

432 cmd = self._build_command( 

433 files=mypy_targets, 

434 excludes=mypy_excludes, 

435 exclude_regexes=mypy_exclude_regexes, 

436 ) 

437 logger.debug("[mypy] Running with cwd={} and cmd={}", effective_cwd, cmd) 

438 

439 try: 

440 success, output = self._run_subprocess( 

441 cmd=cmd, 

442 timeout=ctx.timeout, 

443 cwd=effective_cwd, 

444 ) 

445 except subprocess.TimeoutExpired: 

446 timeout_result = create_timeout_result( 

447 tool=self, 

448 timeout=ctx.timeout, 

449 cmd=cmd, 

450 ) 

451 return ToolResult( 

452 name=self.definition.name, 

453 success=timeout_result.success, 

454 output=timeout_result.output, 

455 issues_count=timeout_result.issues_count, 

456 issues=timeout_result.issues, 

457 ) 

458 except FileNotFoundError as e: 

459 return ToolResult( 

460 name=self.definition.name, 

461 success=False, 

462 output=f"mypy not found: {e}\n\n" 

463 "Please ensure mypy is installed:\n" 

464 " - Run 'pip install mypy' or 'uv pip install mypy'", 

465 issues_count=0, 

466 ) 

467 except (OSError, ValueError, RuntimeError) as e: 

468 logger.error("Failed to run mypy: {}", e) 

469 return ToolResult( 

470 name=self.definition.name, 

471 success=False, 

472 output=f"mypy execution failed: {e}", 

473 issues_count=0, 

474 ) 

475 

476 issues = parse_mypy_output(output=output) 

477 issues_count = len(issues) 

478 

479 if not success and issues_count == 0: 

480 # Execution failed but no structured issues were parsed; surface raw output 

481 return ToolResult( 

482 name=self.definition.name, 

483 success=False, 

484 output=output or "mypy execution failed.", 

485 issues_count=0, 

486 ) 

487 

488 return ToolResult( 

489 name=self.definition.name, 

490 success=issues_count == 0, 

491 output=None, 

492 issues_count=issues_count, 

493 issues=issues, 

494 ) 

495 

496 def fix(self, paths: list[str], options: dict[str, object]) -> ToolResult: 

497 """Mypy does not support auto-fixing. 

498 

499 Args: 

500 paths: Paths or files passed for completeness. 

501 options: Runtime options (unused). 

502 

503 Returns: 

504 ToolResult: Never returns, always raises NotImplementedError. 

505 

506 Raises: 

507 NotImplementedError: Always, because mypy cannot fix issues. 

508 """ 

509 raise NotImplementedError( 

510 "Mypy cannot automatically fix issues. Run 'lintro check' to see " 

511 "type errors that need manual correction.", 

512 )