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
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Yamllint tool definition.
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"""
8from __future__ import annotations
10import fnmatch
11import os
12import subprocess # nosec B404 - used safely with shell disabled
13from dataclasses import dataclass
14from typing import Any
16import click
17from loguru import logger
19try:
20 import yaml
21except ImportError:
22 yaml = None # type: ignore[assignment]
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)
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)
54@register_tool
55@dataclass
56class YamllintPlugin(BaseToolPlugin):
57 """Yamllint YAML linter plugin.
59 This plugin integrates Yamllint with Lintro for checking YAML files
60 for syntax errors and style issues.
61 """
63 @property
64 def definition(self) -> ToolDefinition:
65 """Return the tool definition.
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 )
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.
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()
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")
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)
135 def _find_yamllint_config(self, search_dir: str | None = None) -> str | None:
136 """Locate yamllint config file if not explicitly provided.
138 Yamllint searches upward from the file's directory to find config files,
139 so we do the same to match native behavior.
141 Args:
142 search_dir: Directory to start searching from. If None, searches from
143 current working directory.
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)
153 # If config_data is set, don't search for config file
154 if self.options.get("config_data"):
155 return None
157 # Check for config files in order of precedence
158 config_paths = [
159 ".yamllint",
160 ".yamllint.yml",
161 ".yamllint.yaml",
162 ]
164 start_dir = os.path.abspath(search_dir) if search_dir else os.getcwd()
165 current_dir = start_dir
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
177 parent_dir = os.path.dirname(current_dir)
178 if parent_dir == current_dir:
179 break
180 current_dir = parent_dir
182 return None
184 def _load_yamllint_ignore_patterns(
185 self,
186 config_file: str | None,
187 ) -> list[str]:
188 """Load ignore patterns from yamllint config file.
190 Args:
191 config_file: Path to yamllint config file, or None.
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 []
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
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 )
238 return ignore_patterns
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.
247 Args:
248 file_path: Path to the file to check.
249 ignore_patterns: List of ignore patterns from yamllint config.
251 Returns:
252 bool: True if the file should be ignored, False otherwise.
253 """
254 if not ignore_patterns:
255 return False
257 normalized_path: str = file_path.replace("\\", "/")
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
270 return False
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.
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)
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])
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 )
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")
313 cmd.append(abs_file)
314 logger.debug(f"[YamllintPlugin] Processing file: {abs_file}")
315 logger.debug(f"[YamllintPlugin] Command: {' '.join(cmd)}")
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)
326 if not success:
327 results["all_success"] = False
328 results["total_issues"] += issues_count
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
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
355 def doc_url(self, code: str) -> str | None:
356 """Return yamllint documentation URL for the given rule.
358 Args:
359 code: Yamllint rule name (e.g., "line-length").
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
369 def check(self, paths: list[str], options: dict[str, object]) -> ToolResult:
370 """Check files with Yamllint.
372 Args:
373 paths: List of file or directory paths to check.
374 options: Runtime options that override defaults.
376 Returns:
377 ToolResult with check results.
378 """
379 # Merge runtime options
380 merged_options = dict(self.options)
381 merged_options.update(options)
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]
392 yaml_files = ctx.files
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 )
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)
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 )
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 )
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 }
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)
462 # Build combined output from all collected outputs
463 combined_output = (
464 "\n".join(results["all_outputs"]) if results["all_outputs"] else None
465 )
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 )
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 )
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 )
500 def fix(self, paths: list[str], options: dict[str, object]) -> ToolResult:
501 """Yamllint cannot fix issues, only report them.
503 Args:
504 paths: List of file or directory paths (unused).
505 options: Runtime options (unused).
507 Returns:
508 ToolResult: Never returns, always raises NotImplementedError.
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 )