lintro 0.3.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 +3 -0
- lintro/__main__.py +6 -0
- lintro/ascii-art/fail.txt +404 -0
- lintro/ascii-art/success.txt +484 -0
- lintro/cli.py +70 -0
- lintro/cli_utils/__init__.py +7 -0
- lintro/cli_utils/commands/__init__.py +7 -0
- lintro/cli_utils/commands/check.py +210 -0
- lintro/cli_utils/commands/format.py +167 -0
- lintro/cli_utils/commands/list_tools.py +114 -0
- lintro/enums/__init__.py +0 -0
- lintro/enums/action.py +29 -0
- lintro/enums/darglint_strictness.py +22 -0
- lintro/enums/group_by.py +31 -0
- lintro/enums/hadolint_enums.py +46 -0
- lintro/enums/output_format.py +40 -0
- lintro/enums/tool_name.py +36 -0
- lintro/enums/tool_type.py +27 -0
- lintro/enums/yamllint_format.py +22 -0
- lintro/exceptions/__init__.py +0 -0
- lintro/exceptions/errors.py +15 -0
- lintro/formatters/__init__.py +0 -0
- lintro/formatters/core/__init__.py +0 -0
- lintro/formatters/core/output_style.py +21 -0
- lintro/formatters/core/table_descriptor.py +24 -0
- lintro/formatters/styles/__init__.py +17 -0
- lintro/formatters/styles/csv.py +41 -0
- lintro/formatters/styles/grid.py +91 -0
- lintro/formatters/styles/html.py +48 -0
- lintro/formatters/styles/json.py +61 -0
- lintro/formatters/styles/markdown.py +41 -0
- lintro/formatters/styles/plain.py +39 -0
- lintro/formatters/tools/__init__.py +35 -0
- lintro/formatters/tools/darglint_formatter.py +72 -0
- lintro/formatters/tools/hadolint_formatter.py +84 -0
- lintro/formatters/tools/prettier_formatter.py +76 -0
- lintro/formatters/tools/ruff_formatter.py +116 -0
- lintro/formatters/tools/yamllint_formatter.py +87 -0
- lintro/models/__init__.py +0 -0
- lintro/models/core/__init__.py +0 -0
- lintro/models/core/tool.py +104 -0
- lintro/models/core/tool_config.py +23 -0
- lintro/models/core/tool_result.py +39 -0
- lintro/parsers/__init__.py +0 -0
- lintro/parsers/darglint/__init__.py +0 -0
- lintro/parsers/darglint/darglint_issue.py +9 -0
- lintro/parsers/darglint/darglint_parser.py +62 -0
- lintro/parsers/hadolint/__init__.py +1 -0
- lintro/parsers/hadolint/hadolint_issue.py +24 -0
- lintro/parsers/hadolint/hadolint_parser.py +65 -0
- lintro/parsers/prettier/__init__.py +0 -0
- lintro/parsers/prettier/prettier_issue.py +10 -0
- lintro/parsers/prettier/prettier_parser.py +60 -0
- lintro/parsers/ruff/__init__.py +1 -0
- lintro/parsers/ruff/ruff_issue.py +43 -0
- lintro/parsers/ruff/ruff_parser.py +89 -0
- lintro/parsers/yamllint/__init__.py +0 -0
- lintro/parsers/yamllint/yamllint_issue.py +24 -0
- lintro/parsers/yamllint/yamllint_parser.py +68 -0
- lintro/tools/__init__.py +40 -0
- lintro/tools/core/__init__.py +0 -0
- lintro/tools/core/tool_base.py +320 -0
- lintro/tools/core/tool_manager.py +167 -0
- lintro/tools/implementations/__init__.py +0 -0
- lintro/tools/implementations/tool_darglint.py +245 -0
- lintro/tools/implementations/tool_hadolint.py +302 -0
- lintro/tools/implementations/tool_prettier.py +270 -0
- lintro/tools/implementations/tool_ruff.py +618 -0
- lintro/tools/implementations/tool_yamllint.py +240 -0
- lintro/tools/tool_enum.py +17 -0
- lintro/utils/__init__.py +0 -0
- lintro/utils/ascii_normalize_cli.py +84 -0
- lintro/utils/config.py +39 -0
- lintro/utils/console_logger.py +783 -0
- lintro/utils/formatting.py +173 -0
- lintro/utils/output_manager.py +301 -0
- lintro/utils/path_utils.py +41 -0
- lintro/utils/tool_executor.py +443 -0
- lintro/utils/tool_utils.py +431 -0
- lintro-0.3.2.dist-info/METADATA +338 -0
- lintro-0.3.2.dist-info/RECORD +85 -0
- lintro-0.3.2.dist-info/WHEEL +5 -0
- lintro-0.3.2.dist-info/entry_points.txt +2 -0
- lintro-0.3.2.dist-info/licenses/LICENSE +21 -0
- lintro-0.3.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Formatting utilities for core output.
|
|
2
|
+
|
|
3
|
+
Includes helpers to read multi-section ASCII art files and normalize
|
|
4
|
+
ASCII blocks to a fixed size (width/height) while preserving shape.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import random
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def read_ascii_art(filename: str) -> list[str]:
|
|
12
|
+
"""Read ASCII art from a file.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
filename: Name of the ASCII art file.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
List of lines from one randomly selected ASCII art section.
|
|
19
|
+
"""
|
|
20
|
+
try:
|
|
21
|
+
# Get the path to the ASCII art file
|
|
22
|
+
ascii_art_dir: Path = Path(__file__).parent.parent / "ascii-art"
|
|
23
|
+
file_path: Path = ascii_art_dir / filename
|
|
24
|
+
|
|
25
|
+
# Read the file and parse sections
|
|
26
|
+
with file_path.open("r", encoding="utf-8") as f:
|
|
27
|
+
lines: list[str] = [line.rstrip() for line in f.readlines()]
|
|
28
|
+
|
|
29
|
+
# Find non-empty sections (separated by empty lines)
|
|
30
|
+
sections: list[list[str]] = []
|
|
31
|
+
current_section: list[str] = []
|
|
32
|
+
|
|
33
|
+
for line in lines:
|
|
34
|
+
if line.strip():
|
|
35
|
+
current_section.append(line)
|
|
36
|
+
elif current_section:
|
|
37
|
+
sections.append(current_section)
|
|
38
|
+
current_section = []
|
|
39
|
+
|
|
40
|
+
# Add the last section if it's not empty
|
|
41
|
+
if current_section:
|
|
42
|
+
sections.append(current_section)
|
|
43
|
+
|
|
44
|
+
# Return a random section if there are multiple, otherwise return all lines
|
|
45
|
+
if sections:
|
|
46
|
+
return random.choice(sections)
|
|
47
|
+
return lines
|
|
48
|
+
except (FileNotFoundError, OSError):
|
|
49
|
+
# Return empty list if file not found or can't be read
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def normalize_ascii_block(
|
|
54
|
+
lines: list[str],
|
|
55
|
+
*,
|
|
56
|
+
width: int,
|
|
57
|
+
height: int,
|
|
58
|
+
align: str = "center",
|
|
59
|
+
valign: str = "middle",
|
|
60
|
+
) -> list[str]:
|
|
61
|
+
"""Normalize an ASCII block to a fixed width/height.
|
|
62
|
+
|
|
63
|
+
Lines are trimmed on the right only (rstrip), then padded to ``width``.
|
|
64
|
+
If a line exceeds ``width``, it is truncated. The whole block is then
|
|
65
|
+
vertically padded/truncated to ``height``.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
lines: Original ASCII block lines.
|
|
69
|
+
width: Target width in characters.
|
|
70
|
+
height: Target height in lines.
|
|
71
|
+
align: Horizontal alignment: 'left', 'center', or 'right'.
|
|
72
|
+
valign: Vertical alignment: 'top', 'middle', or 'bottom'.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
list[str]: Normalized lines of length == ``height`` where each line
|
|
76
|
+
has exactly ``width`` characters.
|
|
77
|
+
"""
|
|
78
|
+
if width <= 0 or height <= 0:
|
|
79
|
+
return []
|
|
80
|
+
|
|
81
|
+
def _pad_line(s: str) -> str:
|
|
82
|
+
s = s.rstrip("\n").rstrip()
|
|
83
|
+
# Truncate if necessary
|
|
84
|
+
if len(s) > width:
|
|
85
|
+
return s[:width]
|
|
86
|
+
space = width - len(s)
|
|
87
|
+
if align == "left":
|
|
88
|
+
return s + (" " * space)
|
|
89
|
+
if align == "right":
|
|
90
|
+
return (" " * space) + s
|
|
91
|
+
# center
|
|
92
|
+
left = space // 2
|
|
93
|
+
right = space - left
|
|
94
|
+
return (" " * left) + s + (" " * right)
|
|
95
|
+
|
|
96
|
+
padded_lines: list[str] = [_pad_line(line) for line in lines]
|
|
97
|
+
|
|
98
|
+
# Vertical pad/truncate
|
|
99
|
+
if len(padded_lines) >= height:
|
|
100
|
+
# Truncate based on valign
|
|
101
|
+
if valign == "top":
|
|
102
|
+
return padded_lines[:height]
|
|
103
|
+
if valign == "bottom":
|
|
104
|
+
return padded_lines[-height:]
|
|
105
|
+
# middle
|
|
106
|
+
extra = len(padded_lines) - height
|
|
107
|
+
top_cut = extra // 2
|
|
108
|
+
return padded_lines[top_cut : top_cut + height]
|
|
109
|
+
|
|
110
|
+
# Need to add blank lines
|
|
111
|
+
blank = " " * width
|
|
112
|
+
missing = height - len(padded_lines)
|
|
113
|
+
if valign == "top":
|
|
114
|
+
return padded_lines + [blank] * missing
|
|
115
|
+
if valign == "bottom":
|
|
116
|
+
return [blank] * missing + padded_lines
|
|
117
|
+
top_pad = missing // 2
|
|
118
|
+
bottom_pad = missing - top_pad
|
|
119
|
+
return [blank] * top_pad + padded_lines + [blank] * bottom_pad
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def normalize_ascii_file_sections(
|
|
123
|
+
file_path: Path,
|
|
124
|
+
*,
|
|
125
|
+
width: int,
|
|
126
|
+
height: int,
|
|
127
|
+
align: str = "center",
|
|
128
|
+
valign: str = "middle",
|
|
129
|
+
) -> list[list[str]]:
|
|
130
|
+
"""Read a multi-section ASCII file and normalize all sections.
|
|
131
|
+
|
|
132
|
+
Sections are separated by empty lines. Each section is normalized
|
|
133
|
+
independently and returned as a list of lines.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
file_path: Path to the ASCII art file.
|
|
137
|
+
width: Target width.
|
|
138
|
+
height: Target height.
|
|
139
|
+
align: Horizontal alignment.
|
|
140
|
+
valign: Vertical alignment.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
list[list[str]]: List of normalized sections.
|
|
144
|
+
"""
|
|
145
|
+
try:
|
|
146
|
+
with file_path.open("r", encoding="utf-8") as f:
|
|
147
|
+
raw_lines: list[str] = [line.rstrip("\n") for line in f]
|
|
148
|
+
except (FileNotFoundError, OSError):
|
|
149
|
+
return []
|
|
150
|
+
|
|
151
|
+
sections: list[list[str]] = []
|
|
152
|
+
current: list[str] = []
|
|
153
|
+
for line in raw_lines:
|
|
154
|
+
if line.strip() == "":
|
|
155
|
+
if current:
|
|
156
|
+
sections.append(current)
|
|
157
|
+
current = []
|
|
158
|
+
else:
|
|
159
|
+
current.append(line)
|
|
160
|
+
if current:
|
|
161
|
+
sections.append(current)
|
|
162
|
+
|
|
163
|
+
normalized: list[list[str]] = [
|
|
164
|
+
normalize_ascii_block(
|
|
165
|
+
sec,
|
|
166
|
+
width=width,
|
|
167
|
+
height=height,
|
|
168
|
+
align=align,
|
|
169
|
+
valign=valign,
|
|
170
|
+
)
|
|
171
|
+
for sec in sections
|
|
172
|
+
]
|
|
173
|
+
return normalized
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Output manager for Lintro auto-generated results.
|
|
2
|
+
|
|
3
|
+
Handles creation of timestamped output directories and writing all required formats.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import csv
|
|
7
|
+
import datetime
|
|
8
|
+
import html
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from lintro.models.core.tool_result import ToolResult
|
|
15
|
+
|
|
16
|
+
# Constants
|
|
17
|
+
DEFAULT_BASE_DIR: str = ".lintro"
|
|
18
|
+
DEFAULT_KEEP_LAST: int = 10
|
|
19
|
+
DEFAULT_TIMESTAMP_FORMAT: str = "%Y%m%d-%H%M%S"
|
|
20
|
+
DEFAULT_RUN_PREFIX: str = "run-"
|
|
21
|
+
DEFAULT_TEMP_PREFIX: str = ".lintro"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _markdown_escape(text: str) -> str:
|
|
25
|
+
"""Escape text for Markdown formatting.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
text: str: Text to escape.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
str: Escaped text safe for Markdown.
|
|
32
|
+
"""
|
|
33
|
+
return text.replace("|", r"\|").replace("\n", " ")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _html_escape(text: str) -> str:
|
|
37
|
+
"""Escape text for HTML formatting.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
text: str: Text to escape.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
str: Escaped text safe for HTML.
|
|
44
|
+
"""
|
|
45
|
+
return html.escape(text)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class OutputManager:
|
|
49
|
+
"""Manages output directories and result files for Lintro runs.
|
|
50
|
+
|
|
51
|
+
This class creates a timestamped directory under .lintro/run-{timestamp}/
|
|
52
|
+
and provides methods to write all required output formats.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
base_dir: str = DEFAULT_BASE_DIR,
|
|
58
|
+
keep_last: int = DEFAULT_KEEP_LAST,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Initialize the OutputManager.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
base_dir: str: Base directory for output (default: .lintro).
|
|
64
|
+
keep_last: int: Number of runs to keep (default: 10).
|
|
65
|
+
"""
|
|
66
|
+
# Allow override via environment variable
|
|
67
|
+
env_base_dir: str | None = os.environ.get("LINTRO_LOG_DIR")
|
|
68
|
+
if env_base_dir:
|
|
69
|
+
self.base_dir = Path(env_base_dir)
|
|
70
|
+
else:
|
|
71
|
+
self.base_dir = Path(base_dir)
|
|
72
|
+
self.keep_last = keep_last
|
|
73
|
+
self.run_dir = self._create_run_dir()
|
|
74
|
+
|
|
75
|
+
def _create_run_dir(self) -> Path:
|
|
76
|
+
"""Create a new timestamped run directory.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Path: Path to the created run directory.
|
|
80
|
+
"""
|
|
81
|
+
timestamp: str = datetime.datetime.now().strftime(DEFAULT_TIMESTAMP_FORMAT)
|
|
82
|
+
run_dir: Path = self.base_dir / f"{DEFAULT_RUN_PREFIX}{timestamp}"
|
|
83
|
+
try:
|
|
84
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
except PermissionError:
|
|
86
|
+
# Fallback to temp directory if not writable
|
|
87
|
+
temp_base: Path = Path(tempfile.gettempdir()) / DEFAULT_TEMP_PREFIX
|
|
88
|
+
run_dir = temp_base / f"{DEFAULT_RUN_PREFIX}{timestamp}"
|
|
89
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
return run_dir
|
|
91
|
+
|
|
92
|
+
def write_console_log(
|
|
93
|
+
self,
|
|
94
|
+
content: str,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Write the console log to console.log in the run directory.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
content: str: The console output as a string.
|
|
100
|
+
"""
|
|
101
|
+
(self.run_dir / "console.log").write_text(content, encoding="utf-8")
|
|
102
|
+
|
|
103
|
+
def write_json(
|
|
104
|
+
self,
|
|
105
|
+
data: object,
|
|
106
|
+
filename: str = "results.json",
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Write data as JSON to the run directory.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
data: object: The data to serialize as JSON.
|
|
112
|
+
filename: str: The output filename (default: results.json).
|
|
113
|
+
"""
|
|
114
|
+
import json
|
|
115
|
+
|
|
116
|
+
with open(self.run_dir / filename, "w", encoding="utf-8") as f:
|
|
117
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
118
|
+
|
|
119
|
+
def write_markdown(
|
|
120
|
+
self,
|
|
121
|
+
content: str,
|
|
122
|
+
filename: str = "report.md",
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Write Markdown content to the run directory.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
content: str: Markdown content as a string.
|
|
128
|
+
filename: str: The output filename (default: report.md).
|
|
129
|
+
"""
|
|
130
|
+
(self.run_dir / filename).write_text(content, encoding="utf-8")
|
|
131
|
+
|
|
132
|
+
def write_html(
|
|
133
|
+
self,
|
|
134
|
+
content: str,
|
|
135
|
+
filename: str = "report.html",
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Write HTML content to the run directory.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
content: str: HTML content as a string.
|
|
141
|
+
filename: str: The output filename (default: report.html).
|
|
142
|
+
"""
|
|
143
|
+
(self.run_dir / filename).write_text(content, encoding="utf-8")
|
|
144
|
+
|
|
145
|
+
def write_csv(
|
|
146
|
+
self,
|
|
147
|
+
rows: list[list[str]],
|
|
148
|
+
header: list[str],
|
|
149
|
+
filename: str = "summary.csv",
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Write CSV data to the run directory.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
rows: list[list[str]]: List of rows (each row is a list of strings).
|
|
155
|
+
header: list[str]: List of column headers.
|
|
156
|
+
filename: str: The output filename (default: summary.csv).
|
|
157
|
+
"""
|
|
158
|
+
with open(self.run_dir / filename, "w", encoding="utf-8", newline="") as f:
|
|
159
|
+
writer = csv.writer(f)
|
|
160
|
+
writer.writerow(header)
|
|
161
|
+
writer.writerows(rows)
|
|
162
|
+
|
|
163
|
+
def write_reports_from_results(
|
|
164
|
+
self,
|
|
165
|
+
results: list["ToolResult"],
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Generate and write Markdown, HTML, and CSV reports from tool results.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
results: list["ToolResult"]: List of ToolResult objects from a Lintro run.
|
|
171
|
+
"""
|
|
172
|
+
self._write_markdown_report(results=results)
|
|
173
|
+
self._write_html_report(results=results)
|
|
174
|
+
self._write_csv_summary(results=results)
|
|
175
|
+
|
|
176
|
+
def _write_markdown_report(
|
|
177
|
+
self,
|
|
178
|
+
results: list["ToolResult"],
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Write a Markdown report summarizing all tool results and issues.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
results: list["ToolResult"]: List of ToolResult objects from the linting
|
|
184
|
+
run.
|
|
185
|
+
"""
|
|
186
|
+
lines: list[str] = ["# Lintro Report", ""]
|
|
187
|
+
lines.append("## Summary\n")
|
|
188
|
+
lines.append("| Tool | Issues |")
|
|
189
|
+
lines.append("|------|--------|")
|
|
190
|
+
for r in results:
|
|
191
|
+
lines.append(f"| {r.name} | {r.issues_count} |")
|
|
192
|
+
lines.append("")
|
|
193
|
+
for r in results:
|
|
194
|
+
lines.append(f"### {r.name} ({r.issues_count} issues)")
|
|
195
|
+
if hasattr(r, "issues") and r.issues:
|
|
196
|
+
lines.append("| File | Line | Code | Message |")
|
|
197
|
+
lines.append("|------|------|------|---------|")
|
|
198
|
+
for issue in r.issues:
|
|
199
|
+
file: str = _markdown_escape(getattr(issue, "file", ""))
|
|
200
|
+
line = getattr(issue, "line", "")
|
|
201
|
+
code: str = _markdown_escape(getattr(issue, "code", ""))
|
|
202
|
+
msg: str = _markdown_escape(getattr(issue, "message", ""))
|
|
203
|
+
lines.append(f"| {file} | {line} | {code} | {msg} |")
|
|
204
|
+
lines.append("")
|
|
205
|
+
else:
|
|
206
|
+
lines.append("No issues found.\n")
|
|
207
|
+
self.write_markdown(content="\n".join(lines))
|
|
208
|
+
|
|
209
|
+
def _write_html_report(
|
|
210
|
+
self,
|
|
211
|
+
results: list["ToolResult"],
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Write an HTML report summarizing all tool results and issues.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
results: list["ToolResult"]: List of ToolResult objects from the linting
|
|
217
|
+
run.
|
|
218
|
+
"""
|
|
219
|
+
html: list[str] = ["<html><head><title>Lintro Report</title></head><body>"]
|
|
220
|
+
html.append("<h1>Lintro Report</h1>")
|
|
221
|
+
html.append("<h2>Summary</h2>")
|
|
222
|
+
html.append("<table border='1'><tr><th>Tool</th><th>Issues</th></tr>")
|
|
223
|
+
for r in results:
|
|
224
|
+
html.append(
|
|
225
|
+
f"<tr><td>{_html_escape(r.name)}</td><td>{r.issues_count}</td></tr>",
|
|
226
|
+
)
|
|
227
|
+
html.append("</table>")
|
|
228
|
+
for r in results:
|
|
229
|
+
html.append(f"<h3>{_html_escape(r.name)} ({r.issues_count} issues)</h3>")
|
|
230
|
+
if hasattr(r, "issues") and r.issues:
|
|
231
|
+
html.append(
|
|
232
|
+
"<table border='1'><tr><th>File</th><th>Line</th><th>Code</th>"
|
|
233
|
+
"<th>Message</th></tr>"
|
|
234
|
+
)
|
|
235
|
+
for issue in r.issues:
|
|
236
|
+
file: str = _html_escape(getattr(issue, "file", ""))
|
|
237
|
+
line = getattr(issue, "line", "")
|
|
238
|
+
code: str = _html_escape(getattr(issue, "code", ""))
|
|
239
|
+
msg: str = _html_escape(getattr(issue, "message", ""))
|
|
240
|
+
html.append(
|
|
241
|
+
f"<tr><td>{file}</td><td>{line}</td><td>{code}</td>"
|
|
242
|
+
f"<td>{msg}</td></tr>"
|
|
243
|
+
)
|
|
244
|
+
html.append("</table>")
|
|
245
|
+
else:
|
|
246
|
+
html.append("<p>No issues found.</p>")
|
|
247
|
+
html.append("</body></html>")
|
|
248
|
+
self.write_html(content="\n".join(html))
|
|
249
|
+
|
|
250
|
+
def _write_csv_summary(
|
|
251
|
+
self,
|
|
252
|
+
results: list["ToolResult"],
|
|
253
|
+
) -> None:
|
|
254
|
+
"""Write a CSV summary of all tool results and issues.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
results: list["ToolResult"]: List of ToolResult objects from the linting
|
|
258
|
+
run.
|
|
259
|
+
"""
|
|
260
|
+
rows: list[list[str]] = []
|
|
261
|
+
header: list[str] = ["tool", "issues_count", "file", "line", "code", "message"]
|
|
262
|
+
for r in results:
|
|
263
|
+
if hasattr(r, "issues") and r.issues:
|
|
264
|
+
for issue in r.issues:
|
|
265
|
+
rows.append(
|
|
266
|
+
[
|
|
267
|
+
r.name,
|
|
268
|
+
r.issues_count,
|
|
269
|
+
getattr(issue, "file", ""),
|
|
270
|
+
getattr(issue, "line", ""),
|
|
271
|
+
getattr(issue, "code", ""),
|
|
272
|
+
getattr(issue, "message", ""),
|
|
273
|
+
],
|
|
274
|
+
)
|
|
275
|
+
else:
|
|
276
|
+
rows.append([r.name, r.issues_count, "", "", "", ""])
|
|
277
|
+
self.write_csv(rows=rows, header=header)
|
|
278
|
+
|
|
279
|
+
def cleanup_old_runs(self) -> None:
|
|
280
|
+
"""Remove old run directories, keeping only the most recent N runs."""
|
|
281
|
+
if not self.base_dir.exists():
|
|
282
|
+
return
|
|
283
|
+
runs: list[Path] = sorted(
|
|
284
|
+
[
|
|
285
|
+
d
|
|
286
|
+
for d in self.base_dir.iterdir()
|
|
287
|
+
if d.is_dir() and d.name.startswith(DEFAULT_RUN_PREFIX)
|
|
288
|
+
],
|
|
289
|
+
key=lambda d: d.name,
|
|
290
|
+
reverse=True,
|
|
291
|
+
)
|
|
292
|
+
for old_run in runs[self.keep_last :]:
|
|
293
|
+
shutil.rmtree(old_run)
|
|
294
|
+
|
|
295
|
+
def get_run_dir(self) -> Path:
|
|
296
|
+
"""Get the current run directory.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
Path: Path to the current run directory.
|
|
300
|
+
"""
|
|
301
|
+
return self.run_dir
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Path utilities for Lintro.
|
|
2
|
+
|
|
3
|
+
Small helpers to normalize paths for display consistency.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def normalize_file_path_for_display(file_path: str) -> str:
|
|
10
|
+
"""Normalize file path to be relative to project root for consistent display.
|
|
11
|
+
|
|
12
|
+
This ensures all tools show file paths in the same format:
|
|
13
|
+
- Relative to project root (like ./src/file.py)
|
|
14
|
+
- Consistent across all tools regardless of how they output paths
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
file_path: File path (can be absolute or relative). If empty, returns as is.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Normalized relative path from project root (e.g., "./src/file.py")
|
|
21
|
+
"""
|
|
22
|
+
# Fast-path: empty or whitespace-only input
|
|
23
|
+
if not file_path or not str(file_path).strip():
|
|
24
|
+
return file_path
|
|
25
|
+
try:
|
|
26
|
+
# Get the current working directory (project root)
|
|
27
|
+
project_root: str = os.getcwd()
|
|
28
|
+
|
|
29
|
+
# Convert to absolute path first, then make relative to project root
|
|
30
|
+
abs_path: str = os.path.abspath(file_path)
|
|
31
|
+
rel_path: str = os.path.relpath(abs_path, project_root)
|
|
32
|
+
|
|
33
|
+
# Ensure it starts with "./" for consistency (like darglint format)
|
|
34
|
+
if not rel_path.startswith("./") and not rel_path.startswith("../"):
|
|
35
|
+
rel_path = "./" + rel_path
|
|
36
|
+
|
|
37
|
+
return rel_path
|
|
38
|
+
|
|
39
|
+
except (ValueError, OSError):
|
|
40
|
+
# If path normalization fails, return the original path
|
|
41
|
+
return file_path
|