lintro 0.3.2__py3-none-any.whl → 0.4.2__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.

Potentially problematic release.


This version of lintro might be problematic. Click here for more details.

lintro/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Lintro - A unified CLI core for code formatting, linting, and quality assurance."""
2
2
 
3
- __version__ = "0.3.2"
3
+ __version__ = "0.4.2"
@@ -1,5 +1,13 @@
1
1
  """Tool-specific table formatters package exports."""
2
2
 
3
+ from lintro.formatters.tools.actionlint_formatter import (
4
+ ActionlintTableDescriptor,
5
+ format_actionlint_issues,
6
+ )
7
+ from lintro.formatters.tools.bandit_formatter import (
8
+ BanditTableDescriptor,
9
+ format_bandit_issues,
10
+ )
3
11
  from lintro.formatters.tools.darglint_formatter import (
4
12
  DarglintTableDescriptor,
5
13
  format_darglint_issues,
@@ -22,6 +30,10 @@ from lintro.formatters.tools.yamllint_formatter import (
22
30
  )
23
31
 
24
32
  __all__ = [
33
+ "ActionlintTableDescriptor",
34
+ "format_actionlint_issues",
35
+ "BanditTableDescriptor",
36
+ "format_bandit_issues",
25
37
  "DarglintTableDescriptor",
26
38
  "format_darglint_issues",
27
39
  "HadolintTableDescriptor",
@@ -0,0 +1,82 @@
1
+ """Formatter for actionlint issues."""
2
+
3
+ from lintro.formatters.core.table_descriptor import TableDescriptor
4
+ from lintro.formatters.styles.csv import CsvStyle
5
+ from lintro.formatters.styles.grid import GridStyle
6
+ from lintro.formatters.styles.html import HtmlStyle
7
+ from lintro.formatters.styles.json import JsonStyle
8
+ from lintro.formatters.styles.markdown import MarkdownStyle
9
+ from lintro.formatters.styles.plain import PlainStyle
10
+ from lintro.parsers.actionlint.actionlint_issue import ActionlintIssue
11
+ from lintro.utils.path_utils import normalize_file_path_for_display
12
+
13
+ FORMAT_MAP = {
14
+ "plain": PlainStyle(),
15
+ "grid": GridStyle(),
16
+ "markdown": MarkdownStyle(),
17
+ "html": HtmlStyle(),
18
+ "json": JsonStyle(),
19
+ "csv": CsvStyle(),
20
+ }
21
+
22
+
23
+ class ActionlintTableDescriptor(TableDescriptor):
24
+ """Table descriptor for rendering Actionlint issues.
25
+
26
+ Provides the column schema and row extraction logic so any output style
27
+ can render a uniform table across formats (grid, markdown, html, csv, json).
28
+ """
29
+
30
+ def get_columns(self) -> list[str]:
31
+ """Return the ordered column headers for Actionlint issues.
32
+
33
+ Returns:
34
+ list[str]: Column names used by formatters.
35
+ """
36
+ return ["File", "Line", "Column", "Level", "Code", "Message"]
37
+
38
+ def get_rows(self, issues: list[ActionlintIssue]) -> list[list[str]]:
39
+ """Convert Actionlint issues to a list of row values.
40
+
41
+ Args:
42
+ issues: Parsed Actionlint issues to render.
43
+
44
+ Returns:
45
+ list[list[str]]: One row per issue matching the column order.
46
+ """
47
+ rows: list[list[str]] = []
48
+ for issue in issues:
49
+ rows.append(
50
+ [
51
+ normalize_file_path_for_display(issue.file),
52
+ str(issue.line),
53
+ str(issue.column),
54
+ issue.level,
55
+ issue.code or "",
56
+ issue.message,
57
+ ]
58
+ )
59
+ return rows
60
+
61
+
62
+ def format_actionlint_issues(
63
+ issues: list[ActionlintIssue],
64
+ format: str = "grid",
65
+ ) -> str:
66
+ """Format Actionlint issues using the selected output style.
67
+
68
+ Args:
69
+ issues: Parsed Actionlint issues to format.
70
+ format: Output style key (plain, grid, markdown, html, json, csv).
71
+
72
+ Returns:
73
+ str: Rendered table content for the chosen style.
74
+ """
75
+ descriptor = ActionlintTableDescriptor()
76
+ formatter = FORMAT_MAP.get(format, GridStyle())
77
+ columns = descriptor.get_columns()
78
+ rows = descriptor.get_rows(issues)
79
+ # JsonStyle may accept tool_name but others do not; keep simple
80
+ if isinstance(formatter, JsonStyle):
81
+ return formatter.format(columns=columns, rows=rows, tool_name="actionlint")
82
+ return formatter.format(columns=columns, rows=rows)
@@ -0,0 +1,100 @@
1
+ """Bandit formatter for security issue output."""
2
+
3
+ from lintro.formatters.core.table_descriptor import TableDescriptor
4
+ from lintro.parsers.bandit.bandit_issue import BanditIssue
5
+ from lintro.utils.path_utils import normalize_file_path_for_display
6
+
7
+
8
+ class BanditTableDescriptor(TableDescriptor):
9
+ """Table descriptor for Bandit security issues."""
10
+
11
+ def get_columns(self) -> list[str]:
12
+ """Get column headers for Bandit issues table.
13
+
14
+ Returns:
15
+ list[str]: List of column headers.
16
+ """
17
+ return ["File", "Line", "Test ID", "Severity", "Confidence", "Issue"]
18
+
19
+ def get_rows(self, issues: list[BanditIssue]) -> list[list[str]]:
20
+ """Convert Bandit issues to table rows.
21
+
22
+ Args:
23
+ issues: list[BanditIssue]: List of Bandit issues.
24
+
25
+ Returns:
26
+ list[list[str]]: List of table rows.
27
+ """
28
+ rows = []
29
+ for issue in issues:
30
+ # Get severity icon
31
+ severity_icon = self._get_severity_icon(issue.issue_severity)
32
+
33
+ rows.append(
34
+ [
35
+ normalize_file_path_for_display(issue.file),
36
+ str(issue.line),
37
+ issue.test_id,
38
+ f"{severity_icon} {issue.issue_severity}",
39
+ issue.issue_confidence,
40
+ issue.issue_text,
41
+ ]
42
+ )
43
+ return rows
44
+
45
+ def _get_severity_icon(self, severity: str) -> str:
46
+ """Get icon for severity level.
47
+
48
+ Args:
49
+ severity: str: Severity level.
50
+
51
+ Returns:
52
+ str: Icon character.
53
+ """
54
+ return {
55
+ "HIGH": "🔴",
56
+ "MEDIUM": "🟠",
57
+ "LOW": "🟡",
58
+ "UNKNOWN": "⚪",
59
+ }.get(severity.upper(), "⚪")
60
+
61
+
62
+ def format_bandit_issues(
63
+ issues: list[BanditIssue],
64
+ format: str = "grid",
65
+ ) -> str:
66
+ """Format Bandit issues using the appropriate output style.
67
+
68
+ Args:
69
+ issues: list[BanditIssue]: List of Bandit issues to format.
70
+ format: str: Output format (grid, plain, markdown, html, json, csv).
71
+
72
+ Returns:
73
+ str: Formatted issues string.
74
+ """
75
+ from lintro.formatters.styles.csv import CsvStyle
76
+ from lintro.formatters.styles.grid import GridStyle
77
+ from lintro.formatters.styles.html import HtmlStyle
78
+ from lintro.formatters.styles.json import JsonStyle
79
+ from lintro.formatters.styles.markdown import MarkdownStyle
80
+ from lintro.formatters.styles.plain import PlainStyle
81
+
82
+ style_map = {
83
+ "grid": GridStyle(),
84
+ "plain": PlainStyle(),
85
+ "markdown": MarkdownStyle(),
86
+ "html": HtmlStyle(),
87
+ "json": JsonStyle(),
88
+ "csv": CsvStyle(),
89
+ }
90
+
91
+ style_key = (format or "grid").lower()
92
+ style = style_map.get(style_key, GridStyle())
93
+ descriptor = BanditTableDescriptor()
94
+
95
+ columns = descriptor.get_columns()
96
+ rows = descriptor.get_rows(issues)
97
+
98
+ if style_key == "json":
99
+ return style.format(columns=columns, rows=rows, tool_name="bandit")
100
+ return style.format(columns=columns, rows=rows)
@@ -0,0 +1,21 @@
1
+ """Parser modules for Lintro tools."""
2
+
3
+ from lintro.parsers import (
4
+ actionlint,
5
+ bandit,
6
+ darglint,
7
+ hadolint,
8
+ prettier,
9
+ ruff,
10
+ yamllint,
11
+ )
12
+
13
+ __all__ = [
14
+ "actionlint",
15
+ "bandit",
16
+ "darglint",
17
+ "hadolint",
18
+ "prettier",
19
+ "ruff",
20
+ "yamllint",
21
+ ]
@@ -0,0 +1 @@
1
+ """Actionlint parser package."""
@@ -0,0 +1,24 @@
1
+ """Issue model for actionlint output."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class ActionlintIssue:
8
+ """Represents a single actionlint issue parsed from CLI output.
9
+
10
+ Attributes:
11
+ file: File path where the issue occurred.
12
+ line: Line number of the issue (1-based).
13
+ column: Column number of the issue (1-based).
14
+ level: Severity level (e.g., "error", "warning").
15
+ code: Optional rule/code identifier, when present.
16
+ message: Human-readable message describing the issue.
17
+ """
18
+
19
+ file: str
20
+ line: int
21
+ column: int
22
+ level: str
23
+ code: str | None
24
+ message: str
@@ -0,0 +1,67 @@
1
+ """Parser for actionlint CLI output.
2
+
3
+ This module parses the default text output produced by the ``actionlint``
4
+ binary into structured ``ActionlintIssue`` objects so that Lintro can render
5
+ uniform tables and reports across styles.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Iterable
12
+
13
+ from lintro.parsers.actionlint.actionlint_issue import ActionlintIssue
14
+
15
+ _LINE_RE: re.Pattern[str] = re.compile(
16
+ r"^(?P<file>[^:]+):(?P<line>\d+):(?P<col>\d+):\s*(?:(?P<level>error|warning):\s*)?(?P<msg>.*?)(?:\s*\[(?P<code>[A-Za-z0-9_\-\.]+)\])?$",
17
+ )
18
+
19
+
20
+ def parse_actionlint_output(output: str | None) -> list[ActionlintIssue]:
21
+ """Parse raw actionlint output into structured issues.
22
+
23
+ Args:
24
+ output: Raw stdout/stderr combined output from actionlint.
25
+
26
+ Returns:
27
+ list[ActionlintIssue]: Parsed issues from the tool output.
28
+ """
29
+ if not output:
30
+ return []
31
+
32
+ issues: list[ActionlintIssue] = []
33
+ for line in _iter_nonempty_lines(output):
34
+ m = _LINE_RE.match(line.strip())
35
+ if not m:
36
+ continue
37
+ file_path = m.group("file")
38
+ line_no = int(m.group("line"))
39
+ col_no = int(m.group("col"))
40
+ level = m.group("level") or "error"
41
+ msg = m.group("msg").strip()
42
+ code = m.group("code")
43
+ issues.append(
44
+ ActionlintIssue(
45
+ file=file_path,
46
+ line=line_no,
47
+ column=col_no,
48
+ level=level,
49
+ code=code,
50
+ message=msg,
51
+ ),
52
+ )
53
+ return issues
54
+
55
+
56
+ def _iter_nonempty_lines(text: str) -> Iterable[str]:
57
+ """Iterate non-empty lines from a text block.
58
+
59
+ Args:
60
+ text: Input text to split into lines.
61
+
62
+ Yields:
63
+ Non-empty lines stripped of surrounding whitespace.
64
+ """
65
+ for ln in text.splitlines():
66
+ if ln.strip():
67
+ yield ln
lintro/tools/__init__.py CHANGED
@@ -6,6 +6,8 @@ from lintro.models.core.tool_config import ToolConfig
6
6
  from lintro.tools.core.tool_manager import ToolManager
7
7
 
8
8
  # Import core implementations after Tool class definition to avoid circular imports
9
+ from lintro.tools.implementations.tool_actionlint import ActionlintTool
10
+ from lintro.tools.implementations.tool_bandit import BanditTool
9
11
  from lintro.tools.implementations.tool_darglint import DarglintTool
10
12
  from lintro.tools.implementations.tool_hadolint import HadolintTool
11
13
  from lintro.tools.implementations.tool_prettier import PrettierTool
@@ -32,6 +34,8 @@ __all__ = [
32
34
  "ToolEnum",
33
35
  "tool_manager",
34
36
  "AVAILABLE_TOOLS",
37
+ "ActionlintTool",
38
+ "BanditTool",
35
39
  "DarglintTool",
36
40
  "HadolintTool",
37
41
  "PrettierTool",
@@ -2,10 +2,12 @@
2
2
 
3
3
  import os
4
4
  import shutil
5
- import subprocess
5
+ import subprocess # nosec B404 - subprocess used safely with shell=False
6
6
  from abc import ABC, abstractmethod
7
7
  from dataclasses import dataclass, field
8
8
 
9
+ from loguru import logger
10
+
9
11
  from lintro.enums.tool_type import ToolType
10
12
  from lintro.models.core.tool import ToolConfig, ToolResult
11
13
 
@@ -107,9 +109,9 @@ class BaseTool(ABC):
107
109
  continue
108
110
  if line_stripped not in self.exclude_patterns:
109
111
  self.exclude_patterns.append(line_stripped)
110
- except Exception:
112
+ except Exception as e:
111
113
  # Non-fatal if ignore file can't be read
112
- pass
114
+ logger.debug(f"Could not read .lintro-ignore: {e}")
113
115
 
114
116
  # Load default options from config
115
117
  if hasattr(self.config, "options") and self.config.options:
@@ -146,7 +148,7 @@ class BaseTool(ABC):
146
148
  FileNotFoundError: If command executable is not found.
147
149
  """
148
150
  try:
149
- result = subprocess.run(
151
+ result = subprocess.run( # nosec B603 - args list, shell=False
150
152
  cmd,
151
153
  capture_output=True,
152
154
  text=True,
@@ -0,0 +1,151 @@
1
+ """Actionlint integration for lintro.
2
+
3
+ This module wires the `actionlint` CLI into Lintro's tool system. It discovers
4
+ GitHub Actions workflow files, executes `actionlint`, parses its output into
5
+ structured issues, and returns a normalized `ToolResult`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import subprocess # nosec B404 - used safely with shell disabled
11
+ from dataclasses import dataclass, field
12
+
13
+ from loguru import logger
14
+
15
+ from lintro.enums.tool_type import ToolType
16
+ from lintro.models.core.tool import ToolConfig, ToolResult
17
+ from lintro.parsers.actionlint.actionlint_parser import parse_actionlint_output
18
+ from lintro.tools.core.tool_base import BaseTool
19
+ from lintro.utils.tool_utils import walk_files_with_excludes
20
+
21
+ # Defaults
22
+ ACTIONLINT_DEFAULT_TIMEOUT: int = 30
23
+ ACTIONLINT_DEFAULT_PRIORITY: int = 40
24
+ ACTIONLINT_FILE_PATTERNS: list[str] = ["*.yml", "*.yaml"]
25
+
26
+
27
+ @dataclass
28
+ class ActionlintTool(BaseTool):
29
+ """GitHub Actions workflow linter (actionlint).
30
+
31
+ Attributes:
32
+ name: Tool name used across the system.
33
+ description: Human-readable description for listings/help.
34
+ can_fix: Whether the tool can apply fixes (actionlint cannot).
35
+ config: `ToolConfig` with defaults for priority, file patterns, and
36
+ `ToolType` classification.
37
+ """
38
+
39
+ name: str = "actionlint"
40
+ description: str = "Static checker for GitHub Actions workflows"
41
+ can_fix: bool = False
42
+ config: ToolConfig = field(
43
+ default_factory=lambda: ToolConfig(
44
+ priority=ACTIONLINT_DEFAULT_PRIORITY,
45
+ conflicts_with=[],
46
+ file_patterns=ACTIONLINT_FILE_PATTERNS,
47
+ tool_type=ToolType.LINTER | ToolType.INFRASTRUCTURE,
48
+ options={
49
+ "timeout": ACTIONLINT_DEFAULT_TIMEOUT,
50
+ # Option placeholders for future extension (e.g., color, format)
51
+ },
52
+ ),
53
+ )
54
+
55
+ def _build_command(self) -> list[str]:
56
+ """Build the base actionlint command.
57
+
58
+ We intentionally avoid flags here for maximum portability across
59
+ platforms and actionlint versions. The tool's default text output
60
+ follows the conventional ``file:line:col: message [CODE]`` format,
61
+ which our parser handles directly without requiring a custom format
62
+ switch.
63
+
64
+ Returns:
65
+ The base command list for invoking actionlint.
66
+ """
67
+ return ["actionlint"]
68
+
69
+ def check(self, paths: list[str]) -> ToolResult:
70
+ """Check GitHub Actions workflow files with actionlint.
71
+
72
+ Args:
73
+ paths: File or directory paths to search for workflow files.
74
+
75
+ Returns:
76
+ A `ToolResult` containing success status, aggregated output (if any),
77
+ issue count, and parsed issues.
78
+ """
79
+ self._validate_paths(paths=paths)
80
+ if not paths:
81
+ return ToolResult(
82
+ name=self.name,
83
+ success=True,
84
+ output="No files to check.",
85
+ issues_count=0,
86
+ )
87
+
88
+ candidate_yaml_files: list[str] = walk_files_with_excludes(
89
+ paths=paths,
90
+ file_patterns=self.config.file_patterns,
91
+ exclude_patterns=self.exclude_patterns,
92
+ include_venv=self.include_venv,
93
+ )
94
+ # Restrict to GitHub Actions workflow location
95
+ workflow_files: list[str] = []
96
+ for file_path in candidate_yaml_files:
97
+ norm = file_path.replace("\\", "/")
98
+ if "/.github/workflows/" in norm:
99
+ workflow_files.append(file_path)
100
+ logger.debug(f"Files to check (actionlint): {workflow_files}")
101
+
102
+ if not workflow_files:
103
+ return ToolResult(
104
+ name=self.name,
105
+ success=True,
106
+ output="No GitHub workflow files found to check.",
107
+ issues_count=0,
108
+ )
109
+
110
+ timeout: int = self.options.get("timeout", ACTIONLINT_DEFAULT_TIMEOUT)
111
+ all_outputs: list[str] = []
112
+ all_issues = []
113
+ all_success = True
114
+
115
+ base_cmd = self._build_command()
116
+ for file_path in workflow_files:
117
+ cmd = base_cmd + [file_path]
118
+ try:
119
+ success, output = self._run_subprocess(cmd=cmd, timeout=timeout)
120
+ issues = parse_actionlint_output(output)
121
+ if not success:
122
+ all_success = False
123
+ if issues:
124
+ all_outputs.append(output)
125
+ all_issues.extend(issues)
126
+ except subprocess.TimeoutExpired:
127
+ all_success = False
128
+ all_outputs.append(f"Timeout while checking {file_path}")
129
+ except Exception as e: # pragma: no cover
130
+ all_success = False
131
+ all_outputs.append(f"Error checking {file_path}: {e}")
132
+
133
+ combined_output = "\n".join(all_outputs) if all_outputs else None
134
+ return ToolResult(
135
+ name=self.name,
136
+ success=all_success,
137
+ output=combined_output,
138
+ issues_count=len(all_issues),
139
+ issues=all_issues,
140
+ )
141
+
142
+ def fix(self, paths: list[str]) -> ToolResult:
143
+ """Raise since actionlint cannot apply automatic fixes.
144
+
145
+ Args:
146
+ paths: File or directory paths (ignored).
147
+
148
+ Raises:
149
+ NotImplementedError: Actionlint does not support auto-fixing.
150
+ """
151
+ raise NotImplementedError("actionlint cannot automatically fix issues.")