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.

Files changed (85) hide show
  1. lintro/__init__.py +3 -0
  2. lintro/__main__.py +6 -0
  3. lintro/ascii-art/fail.txt +404 -0
  4. lintro/ascii-art/success.txt +484 -0
  5. lintro/cli.py +70 -0
  6. lintro/cli_utils/__init__.py +7 -0
  7. lintro/cli_utils/commands/__init__.py +7 -0
  8. lintro/cli_utils/commands/check.py +210 -0
  9. lintro/cli_utils/commands/format.py +167 -0
  10. lintro/cli_utils/commands/list_tools.py +114 -0
  11. lintro/enums/__init__.py +0 -0
  12. lintro/enums/action.py +29 -0
  13. lintro/enums/darglint_strictness.py +22 -0
  14. lintro/enums/group_by.py +31 -0
  15. lintro/enums/hadolint_enums.py +46 -0
  16. lintro/enums/output_format.py +40 -0
  17. lintro/enums/tool_name.py +36 -0
  18. lintro/enums/tool_type.py +27 -0
  19. lintro/enums/yamllint_format.py +22 -0
  20. lintro/exceptions/__init__.py +0 -0
  21. lintro/exceptions/errors.py +15 -0
  22. lintro/formatters/__init__.py +0 -0
  23. lintro/formatters/core/__init__.py +0 -0
  24. lintro/formatters/core/output_style.py +21 -0
  25. lintro/formatters/core/table_descriptor.py +24 -0
  26. lintro/formatters/styles/__init__.py +17 -0
  27. lintro/formatters/styles/csv.py +41 -0
  28. lintro/formatters/styles/grid.py +91 -0
  29. lintro/formatters/styles/html.py +48 -0
  30. lintro/formatters/styles/json.py +61 -0
  31. lintro/formatters/styles/markdown.py +41 -0
  32. lintro/formatters/styles/plain.py +39 -0
  33. lintro/formatters/tools/__init__.py +35 -0
  34. lintro/formatters/tools/darglint_formatter.py +72 -0
  35. lintro/formatters/tools/hadolint_formatter.py +84 -0
  36. lintro/formatters/tools/prettier_formatter.py +76 -0
  37. lintro/formatters/tools/ruff_formatter.py +116 -0
  38. lintro/formatters/tools/yamllint_formatter.py +87 -0
  39. lintro/models/__init__.py +0 -0
  40. lintro/models/core/__init__.py +0 -0
  41. lintro/models/core/tool.py +104 -0
  42. lintro/models/core/tool_config.py +23 -0
  43. lintro/models/core/tool_result.py +39 -0
  44. lintro/parsers/__init__.py +0 -0
  45. lintro/parsers/darglint/__init__.py +0 -0
  46. lintro/parsers/darglint/darglint_issue.py +9 -0
  47. lintro/parsers/darglint/darglint_parser.py +62 -0
  48. lintro/parsers/hadolint/__init__.py +1 -0
  49. lintro/parsers/hadolint/hadolint_issue.py +24 -0
  50. lintro/parsers/hadolint/hadolint_parser.py +65 -0
  51. lintro/parsers/prettier/__init__.py +0 -0
  52. lintro/parsers/prettier/prettier_issue.py +10 -0
  53. lintro/parsers/prettier/prettier_parser.py +60 -0
  54. lintro/parsers/ruff/__init__.py +1 -0
  55. lintro/parsers/ruff/ruff_issue.py +43 -0
  56. lintro/parsers/ruff/ruff_parser.py +89 -0
  57. lintro/parsers/yamllint/__init__.py +0 -0
  58. lintro/parsers/yamllint/yamllint_issue.py +24 -0
  59. lintro/parsers/yamllint/yamllint_parser.py +68 -0
  60. lintro/tools/__init__.py +40 -0
  61. lintro/tools/core/__init__.py +0 -0
  62. lintro/tools/core/tool_base.py +320 -0
  63. lintro/tools/core/tool_manager.py +167 -0
  64. lintro/tools/implementations/__init__.py +0 -0
  65. lintro/tools/implementations/tool_darglint.py +245 -0
  66. lintro/tools/implementations/tool_hadolint.py +302 -0
  67. lintro/tools/implementations/tool_prettier.py +270 -0
  68. lintro/tools/implementations/tool_ruff.py +618 -0
  69. lintro/tools/implementations/tool_yamllint.py +240 -0
  70. lintro/tools/tool_enum.py +17 -0
  71. lintro/utils/__init__.py +0 -0
  72. lintro/utils/ascii_normalize_cli.py +84 -0
  73. lintro/utils/config.py +39 -0
  74. lintro/utils/console_logger.py +783 -0
  75. lintro/utils/formatting.py +173 -0
  76. lintro/utils/output_manager.py +301 -0
  77. lintro/utils/path_utils.py +41 -0
  78. lintro/utils/tool_executor.py +443 -0
  79. lintro/utils/tool_utils.py +431 -0
  80. lintro-0.3.2.dist-info/METADATA +338 -0
  81. lintro-0.3.2.dist-info/RECORD +85 -0
  82. lintro-0.3.2.dist-info/WHEEL +5 -0
  83. lintro-0.3.2.dist-info/entry_points.txt +2 -0
  84. lintro-0.3.2.dist-info/licenses/LICENSE +21 -0
  85. 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