Coverage for lintro / tools / definitions / yamllint.py: 60%

195 statements  

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

1"""Yamllint tool definition. 

2 

3Yamllint is a linter for YAML files that checks for syntax validity, 

4key duplications, and cosmetic problems such as lines length, trailing spaces, 

5indentation, etc. 

6""" 

7 

8from __future__ import annotations 

9 

10import fnmatch 

11import os 

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

13from dataclasses import dataclass 

14from typing import Any 

15 

16import click 

17from loguru import logger 

18 

19try: 

20 import yaml 

21except ImportError: 

22 yaml = None # type: ignore[assignment] 

23 

24from lintro.enums.doc_url_template import DocUrlTemplate 

25from lintro.enums.tool_type import ToolType 

26from lintro.enums.yamllint_format import ( 

27 YamllintFormat, 

28 normalize_yamllint_format, 

29) 

30from lintro.models.core.tool_result import ToolResult 

31from lintro.parsers.yamllint.yamllint_parser import parse_yamllint_output 

32from lintro.plugins.base import BaseToolPlugin 

33from lintro.plugins.protocol import ToolDefinition 

34from lintro.plugins.registry import register_tool 

35from lintro.tools.core.option_validators import ( 

36 filter_none_options, 

37 validate_bool, 

38 validate_str, 

39) 

40 

41# Constants for Yamllint configuration 

42YAMLLINT_DEFAULT_TIMEOUT: int = 15 

43YAMLLINT_DEFAULT_PRIORITY: int = 40 

44YAMLLINT_FILE_PATTERNS: list[str] = [ 

45 "*.yml", 

46 "*.yaml", 

47 ".yamllint", 

48 ".yamllint.yml", 

49 ".yamllint.yaml", 

50] 

51YAMLLINT_FORMATS: tuple[str, ...] = tuple(m.name.lower() for m in YamllintFormat) 

52 

53 

54@register_tool 

55@dataclass 

56class YamllintPlugin(BaseToolPlugin): 

57 """Yamllint YAML linter plugin. 

58 

59 This plugin integrates Yamllint with Lintro for checking YAML files 

60 for syntax errors and style issues. 

61 """ 

62 

63 @property 

64 def definition(self) -> ToolDefinition: 

65 """Return the tool definition. 

66 

67 Returns: 

68 ToolDefinition containing tool metadata. 

69 """ 

70 return ToolDefinition( 

71 name="yamllint", 

72 description="YAML linter for syntax and style checking", 

73 can_fix=False, 

74 tool_type=ToolType.LINTER, 

75 file_patterns=YAMLLINT_FILE_PATTERNS, 

76 priority=YAMLLINT_DEFAULT_PRIORITY, 

77 conflicts_with=[], 

78 native_configs=[".yamllint", ".yamllint.yml", ".yamllint.yaml"], 

79 version_command=["yamllint", "--version"], 

80 min_version="1.26.0", 

81 default_options={ 

82 "timeout": YAMLLINT_DEFAULT_TIMEOUT, 

83 "format": "parsable", 

84 "config_file": None, 

85 "config_data": None, 

86 "strict": False, 

87 "relaxed": False, 

88 "no_warnings": False, 

89 }, 

90 default_timeout=YAMLLINT_DEFAULT_TIMEOUT, 

91 ) 

92 

93 def set_options( 

94 self, 

95 format: str | YamllintFormat | None = None, 

96 config_file: str | None = None, 

97 config_data: str | None = None, 

98 strict: bool | None = None, 

99 relaxed: bool | None = None, 

100 no_warnings: bool | None = None, 

101 **kwargs: Any, 

102 ) -> None: 

103 """Set Yamllint-specific options. 

104 

105 Args: 

106 format: Output format (parsable, standard, colored, github, auto). 

107 config_file: Path to yamllint config file. 

108 config_data: Inline config data (YAML string). 

109 strict: Return non-zero exit code on warnings as well as errors. 

110 relaxed: Use relaxed configuration. 

111 no_warnings: Output only error level problems. 

112 **kwargs: Other tool options. 

113 """ 

114 # Normalize format enum if provided 

115 if format is not None: 

116 fmt_enum = normalize_yamllint_format(format) 

117 format = fmt_enum.name.lower() 

118 

119 validate_str(config_file, "config_file") 

120 validate_str(config_data, "config_data") 

121 validate_bool(strict, "strict") 

122 validate_bool(relaxed, "relaxed") 

123 validate_bool(no_warnings, "no_warnings") 

124 

125 options = filter_none_options( 

126 format=format, 

127 config_file=config_file, 

128 config_data=config_data, 

129 strict=strict, 

130 relaxed=relaxed, 

131 no_warnings=no_warnings, 

132 ) 

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

134 

135 def _find_yamllint_config(self, search_dir: str | None = None) -> str | None: 

136 """Locate yamllint config file if not explicitly provided. 

137 

138 Yamllint searches upward from the file's directory to find config files, 

139 so we do the same to match native behavior. 

140 

141 Args: 

142 search_dir: Directory to start searching from. If None, searches from 

143 current working directory. 

144 

145 Returns: 

146 str | None: Path to config file if found, None otherwise. 

147 """ 

148 # If config_file is explicitly set, use it 

149 config_file = self.options.get("config_file") 

150 if config_file: 

151 return str(config_file) 

152 

153 # If config_data is set, don't search for config file 

154 if self.options.get("config_data"): 

155 return None 

156 

157 # Check for config files in order of precedence 

158 config_paths = [ 

159 ".yamllint", 

160 ".yamllint.yml", 

161 ".yamllint.yaml", 

162 ] 

163 

164 start_dir = os.path.abspath(search_dir) if search_dir else os.getcwd() 

165 current_dir = start_dir 

166 

167 while True: 

168 for config_name in config_paths: 

169 config_path = os.path.join(current_dir, config_name) 

170 if os.path.exists(config_path): 

171 logger.debug( 

172 f"[YamllintPlugin] Found config file: {config_path} " 

173 f"(searched from {start_dir})", 

174 ) 

175 return config_path 

176 

177 parent_dir = os.path.dirname(current_dir) 

178 if parent_dir == current_dir: 

179 break 

180 current_dir = parent_dir 

181 

182 return None 

183 

184 def _load_yamllint_ignore_patterns( 

185 self, 

186 config_file: str | None, 

187 ) -> list[str]: 

188 """Load ignore patterns from yamllint config file. 

189 

190 Args: 

191 config_file: Path to yamllint config file, or None. 

192 

193 Returns: 

194 list[str]: List of ignore patterns from the config file. 

195 """ 

196 if not config_file or not os.path.exists(config_file): 

197 return [] 

198 

199 ignore_patterns: list[str] = [] 

200 if yaml is None: 

201 logger.debug( 

202 "[YamllintPlugin] PyYAML not available, cannot parse ignore patterns", 

203 ) 

204 return ignore_patterns 

205 

206 try: 

207 with open(config_file, encoding="utf-8") as f: 

208 config_data = yaml.safe_load(f) 

209 if config_data and isinstance(config_data, dict): 

210 # Check for ignore patterns in line-length rule 

211 line_length_config = config_data.get("rules", {}).get( 

212 "line-length", 

213 {}, 

214 ) 

215 if isinstance(line_length_config, dict): 

216 ignore_value = line_length_config.get("ignore") 

217 if ignore_value: 

218 if isinstance(ignore_value, str): 

219 ignore_patterns.extend( 

220 [ 

221 line.strip() 

222 for line in ignore_value.split("\n") 

223 if line.strip() 

224 ], 

225 ) 

226 elif isinstance(ignore_value, list): 

227 ignore_patterns.extend(ignore_value) 

228 logger.debug( 

229 f"[YamllintPlugin] Loaded {len(ignore_patterns)} ignore " 

230 f"patterns from {config_file}: {ignore_patterns}", 

231 ) 

232 except (OSError, ValueError, KeyError, yaml.YAMLError) as e: 

233 logger.debug( 

234 f"[YamllintPlugin] Failed to load ignore patterns " 

235 f"from {config_file}: {e}", 

236 ) 

237 

238 return ignore_patterns 

239 

240 def _should_ignore_file( 

241 self, 

242 file_path: str, 

243 ignore_patterns: list[str], 

244 ) -> bool: 

245 """Check if a file should be ignored based on yamllint ignore patterns. 

246 

247 Args: 

248 file_path: Path to the file to check. 

249 ignore_patterns: List of ignore patterns from yamllint config. 

250 

251 Returns: 

252 bool: True if the file should be ignored, False otherwise. 

253 """ 

254 if not ignore_patterns: 

255 return False 

256 

257 normalized_path: str = file_path.replace("\\", "/") 

258 

259 for pattern in ignore_patterns: 

260 pattern = pattern.strip() 

261 if not pattern: 

262 continue 

263 if normalized_path.startswith(pattern): 

264 return True 

265 if f"/{pattern}" in normalized_path: 

266 return True 

267 if fnmatch.fnmatch(normalized_path, pattern): 

268 return True 

269 

270 return False 

271 

272 def _process_single_file( 

273 self, 

274 file_path: str, 

275 timeout: int, 

276 results: dict[str, Any], 

277 ) -> None: 

278 """Process a single YAML file with yamllint. 

279 

280 Args: 

281 file_path: Path to the YAML file to process. 

282 timeout: Timeout in seconds for the subprocess call. 

283 results: Dictionary to accumulate results across files. 

284 """ 

285 abs_file: str = os.path.abspath(file_path) 

286 file_dir: str = os.path.dirname(abs_file) 

287 

288 # Build command 

289 cmd: list[str] = self._get_executable_command(tool_name="yamllint") 

290 format_option = str(self.options.get("format", YAMLLINT_FORMATS[0])) 

291 cmd.extend(["--format", format_option]) 

292 

293 # Discover config file relative to the file being checked 

294 config_file: str | None = self._find_yamllint_config(search_dir=file_dir) 

295 if config_file: 

296 abs_config_file = os.path.abspath(config_file) 

297 cmd.extend(["--config-file", abs_config_file]) 

298 logger.debug( 

299 f"[YamllintPlugin] Using config file: {abs_config_file} " 

300 f"(original: {config_file})", 

301 ) 

302 

303 config_data_opt = self.options.get("config_data") 

304 if config_data_opt: 

305 cmd.extend(["--config-data", str(config_data_opt)]) 

306 if self.options.get("strict", False): 

307 cmd.append("--strict") 

308 if self.options.get("relaxed", False): 

309 cmd.append("--relaxed") 

310 if self.options.get("no_warnings", False): 

311 cmd.append("--no-warnings") 

312 

313 cmd.append(abs_file) 

314 logger.debug(f"[YamllintPlugin] Processing file: {abs_file}") 

315 logger.debug(f"[YamllintPlugin] Command: {' '.join(cmd)}") 

316 

317 try: 

318 success, output = self._run_subprocess( 

319 cmd=cmd, 

320 timeout=timeout, 

321 cwd=file_dir, 

322 ) 

323 issues = parse_yamllint_output(output=output) 

324 issues_count = len(issues) 

325 

326 if not success: 

327 results["all_success"] = False 

328 results["total_issues"] += issues_count 

329 

330 # Store raw output when there are issues OR when execution failed 

331 # This ensures error messages are visible even if parsing fails 

332 if output and (issues or not success): 

333 results["all_outputs"].append(output) 

334 if issues: 

335 results["all_issues"].extend(issues) 

336 except subprocess.TimeoutExpired: 

337 results["skipped_files"].append(file_path) 

338 results["all_success"] = False 

339 results["timeout_count"] += 1 

340 except FileNotFoundError: 

341 # File not found - skip silently 

342 pass 

343 except OSError as e: 

344 import errno 

345 

346 if e.errno not in (errno.ENOENT, errno.ENOTDIR): 

347 logger.debug(f"Yamllint execution error for {file_path}: {e}") 

348 results["all_success"] = False 

349 results["execution_failures"] += 1 

350 except (ValueError, RuntimeError) as e: 

351 logger.debug(f"Yamllint execution error for {file_path}: {e}") 

352 results["all_success"] = False 

353 results["execution_failures"] += 1 

354 

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

356 """Return yamllint documentation URL for the given rule. 

357 

358 Args: 

359 code: Yamllint rule name (e.g., "line-length"). 

360 

361 Returns: 

362 URL to the yamllint rule documentation. 

363 """ 

364 normalized = code.strip() if code else "" 

365 if normalized: 

366 return DocUrlTemplate.YAMLLINT.format(code=normalized) 

367 return None 

368 

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

370 """Check files with Yamllint. 

371 

372 Args: 

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

374 options: Runtime options that override defaults. 

375 

376 Returns: 

377 ToolResult with check results. 

378 """ 

379 # Merge runtime options 

380 merged_options = dict(self.options) 

381 merged_options.update(options) 

382 

383 # Use shared preparation for version check, path validation, file discovery 

384 ctx = self._prepare_execution( 

385 paths, 

386 merged_options, 

387 no_files_message="No files to check.", 

388 ) 

389 if ctx.should_skip: 

390 return ctx.early_result # type: ignore[return-value] 

391 

392 yaml_files = ctx.files 

393 

394 logger.debug( 

395 f"[YamllintPlugin] Discovered {len(yaml_files)} files matching patterns: " 

396 f"{self.definition.file_patterns}", 

397 ) 

398 logger.debug( 

399 f"[YamllintPlugin] Exclude patterns applied: {self.exclude_patterns}", 

400 ) 

401 if yaml_files: 

402 logger.debug( 

403 f"[YamllintPlugin] Files to check (first 10): {yaml_files[:10]}", 

404 ) 

405 

406 # Load ignore patterns from yamllint config 

407 config_file = self._find_yamllint_config( 

408 search_dir=paths[0] if paths else None, 

409 ) 

410 ignore_patterns = self._load_yamllint_ignore_patterns(config_file=config_file) 

411 

412 # Filter files based on ignore patterns 

413 if ignore_patterns: 

414 original_count = len(yaml_files) 

415 yaml_files = [ 

416 f 

417 for f in yaml_files 

418 if not self._should_ignore_file( 

419 file_path=f, 

420 ignore_patterns=ignore_patterns, 

421 ) 

422 ] 

423 filtered_count = original_count - len(yaml_files) 

424 if filtered_count > 0: 

425 logger.debug( 

426 f"[YamllintPlugin] Filtered out {filtered_count} files based on " 

427 f"yamllint ignore patterns: {ignore_patterns}", 

428 ) 

429 

430 if not yaml_files: 

431 return ToolResult( 

432 name=self.definition.name, 

433 success=True, 

434 output="No YAML files found to check.", 

435 issues_count=0, 

436 ) 

437 

438 # Accumulate results across all files 

439 results: dict[str, Any] = { 

440 "all_outputs": [], 

441 "all_issues": [], 

442 "all_success": True, 

443 "skipped_files": [], 

444 "timeout_count": 0, 

445 "execution_failures": 0, 

446 "total_issues": 0, 

447 } 

448 

449 # Show progress bar only when processing multiple files 

450 if len(yaml_files) >= 2: 

451 with click.progressbar( 

452 yaml_files, 

453 label="Processing files", 

454 bar_template="%(label)s %(info)s", 

455 ) as bar: 

456 for file_path in bar: 

457 self._process_single_file(file_path, ctx.timeout, results) 

458 else: 

459 for file_path in yaml_files: 

460 self._process_single_file(file_path, ctx.timeout, results) 

461 

462 # Build combined output from all collected outputs 

463 combined_output = ( 

464 "\n".join(results["all_outputs"]) if results["all_outputs"] else None 

465 ) 

466 

467 # Append timeout/failure messages if any 

468 if results["timeout_count"] > 0: 

469 timeout_msg = ( 

470 f"Skipped {results['timeout_count']} file(s) due to timeout " 

471 f"({ctx.timeout}s limit exceeded):" 

472 ) 

473 for file in results["skipped_files"]: 

474 timeout_msg += f"\n - {file}" 

475 combined_output = ( 

476 f"{combined_output}\n\n{timeout_msg}" 

477 if combined_output 

478 else timeout_msg 

479 ) 

480 

481 if results["execution_failures"] > 0: 

482 failure_msg = ( 

483 f"Failed to process {results['execution_failures']} file(s) " 

484 "due to execution errors" 

485 ) 

486 combined_output = ( 

487 f"{combined_output}\n\n{failure_msg}" 

488 if combined_output 

489 else failure_msg 

490 ) 

491 

492 return ToolResult( 

493 name=self.definition.name, 

494 success=results["all_success"], 

495 output=combined_output, 

496 issues_count=results["total_issues"], 

497 issues=results["all_issues"], 

498 ) 

499 

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

501 """Yamllint cannot fix issues, only report them. 

502 

503 Args: 

504 paths: List of file or directory paths (unused). 

505 options: Runtime options (unused). 

506 

507 Returns: 

508 ToolResult: Never returns, always raises NotImplementedError. 

509 

510 Raises: 

511 NotImplementedError: Yamllint does not support fixing issues. 

512 """ 

513 raise NotImplementedError( 

514 "Yamllint cannot automatically fix issues. Use a YAML formatter " 

515 "or manually fix the reported issues.", 

516 )