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,783 @@
1
+ """Simplified Loguru-based logging utility for Lintro.
2
+
3
+ Single responsibility: Handle console display and file logging using Loguru.
4
+ No tee, no stream redirection, clean and simple with rich formatting.
5
+ """
6
+
7
+ import re
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import click
12
+ from loguru import logger
13
+
14
+ from lintro.utils.formatting import read_ascii_art
15
+
16
+ # Constants
17
+ TOOL_EMOJIS: dict[str, str] = {
18
+ "ruff": "🦀",
19
+ "prettier": "💅",
20
+ "darglint": "📝",
21
+ "hadolint": "🐳",
22
+ "yamllint": "📄",
23
+ }
24
+ DEFAULT_EMOJI: str = "🔧"
25
+ BORDER_LENGTH: int = 70
26
+ INFO_BORDER_LENGTH: int = 70
27
+ DEFAULT_REMAINING_COUNT: int = 1
28
+
29
+
30
+ # Regex patterns used to parse tool outputs for remaining issue counts
31
+ # Centralized to avoid repeated long literals and to keep matching logic
32
+ # consistent across the module.
33
+ RE_CANNOT_AUTOFIX: re.Pattern[str] = re.compile(
34
+ r"Found\s+(\d+)\s+issue\(s\)\s+that\s+cannot\s+be\s+auto-fixed",
35
+ )
36
+ RE_REMAINING_OR_CANNOT: re.Pattern[str] = re.compile(
37
+ r"(\d+)\s+(?:issue\(s\)\s+)?(?:that\s+cannot\s+be\s+auto-fixed|remaining)",
38
+ )
39
+
40
+
41
+ def get_tool_emoji(tool_name: str) -> str:
42
+ """Get emoji for a tool.
43
+
44
+ Args:
45
+ tool_name: str: Name of the tool.
46
+
47
+ Returns:
48
+ str: Emoji for the tool.
49
+ """
50
+ return TOOL_EMOJIS.get(tool_name, DEFAULT_EMOJI)
51
+
52
+
53
+ class SimpleLintroLogger:
54
+ """Simplified logger for lintro using Loguru with rich console output."""
55
+
56
+ def __init__(
57
+ self,
58
+ run_dir: Path,
59
+ verbose: bool = False,
60
+ raw_output: bool = False,
61
+ ) -> None:
62
+ """Initialize the logger.
63
+
64
+ Args:
65
+ run_dir: Path: Directory for log files.
66
+ verbose: bool: Whether to enable verbose logging.
67
+ raw_output: bool: Whether to show raw tool output instead of \
68
+ formatted output.
69
+ """
70
+ self.run_dir = run_dir
71
+ self.verbose = verbose
72
+ self.raw_output = raw_output
73
+ self.console_messages: list[str] = [] # Track console output for console.log
74
+
75
+ # Configure Loguru
76
+ self._setup_loguru()
77
+
78
+ def _setup_loguru(self) -> None:
79
+ """Configure Loguru with clean, simple handlers."""
80
+ # Remove default handler
81
+ logger.remove()
82
+
83
+ # Add console handler (for immediate display)
84
+ console_level: str = "DEBUG" if self.verbose else "INFO"
85
+ logger.add(
86
+ sys.stderr,
87
+ level=console_level,
88
+ format=(
89
+ "<green>{time:HH:mm:ss}</green> | <level>{level: <8}</level> | "
90
+ "{message}"
91
+ ),
92
+ colorize=True,
93
+ )
94
+
95
+ # Add debug.log handler (captures everything)
96
+ debug_log_path: Path = self.run_dir / "debug.log"
97
+ logger.add(
98
+ debug_log_path,
99
+ level="DEBUG",
100
+ format=(
101
+ "{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | "
102
+ "{name}:{function}:{line} | {message}"
103
+ ),
104
+ rotation=None, # Don't rotate, each run gets its own file
105
+ )
106
+
107
+ def info(self, message: str, **kwargs) -> None:
108
+ """Log an info message to the console.
109
+
110
+ Args:
111
+ message: str: The message to log.
112
+ **kwargs: Additional keyword arguments for formatting.
113
+ """
114
+ self.console_messages.append(message)
115
+ logger.info(message, **kwargs)
116
+
117
+ def debug(self, message: str, **kwargs) -> None:
118
+ """Log debug message.
119
+
120
+ Args:
121
+ message: str: The debug message to log.
122
+ **kwargs: Additional keyword arguments for formatting.
123
+ """
124
+ logger.debug(message, **kwargs)
125
+
126
+ def warning(self, message: str, **kwargs) -> None:
127
+ """Log a warning message to the console.
128
+
129
+ Args:
130
+ message: str: The message to log.
131
+ **kwargs: Additional keyword arguments for formatting.
132
+ """
133
+ self.console_messages.append(f"WARNING: {message}")
134
+ logger.warning(message, **kwargs)
135
+
136
+ def error(self, message: str, **kwargs) -> None:
137
+ """Log an error message to the console.
138
+
139
+ Args:
140
+ message: str: The message to log.
141
+ **kwargs: Additional keyword arguments for formatting.
142
+ """
143
+ self.console_messages.append(f"ERROR: {message}")
144
+ logger.error(message, **kwargs)
145
+
146
+ def console_output(
147
+ self,
148
+ text: str,
149
+ color: str | None = None,
150
+ ) -> None:
151
+ """Display text on console and track for console.log.
152
+
153
+ Args:
154
+ text: str: Text to display.
155
+ color: str | None: Optional color for the text.
156
+ """
157
+ if color:
158
+ click.echo(click.style(text, fg=color))
159
+ else:
160
+ click.echo(text)
161
+
162
+ # Track for console.log (without color codes)
163
+ self.console_messages.append(text)
164
+
165
+ def success(self, message: str, **kwargs) -> None:
166
+ """Log a success message to the console.
167
+
168
+ Args:
169
+ message: str: The message to log.
170
+ **kwargs: Additional keyword arguments for formatting.
171
+ """
172
+ self.console_output(text=message, color="green")
173
+ logger.debug(f"SUCCESS: {message}")
174
+
175
+ def print_lintro_header(
176
+ self,
177
+ action: str,
178
+ tool_count: int,
179
+ tools_list: str,
180
+ ) -> None:
181
+ """Print the main LINTRO header.
182
+
183
+ Args:
184
+ action: str: The action being performed.
185
+ tool_count: int: The number of tools being run.
186
+ tools_list: str: The list of tools being run.
187
+ """
188
+ header_msg: str = click.style(
189
+ f"[LINTRO] All output formats will be auto-generated in {self.run_dir}",
190
+ fg="cyan",
191
+ bold=True,
192
+ )
193
+ self.console_output(text=header_msg)
194
+ logger.debug(f"Starting {action} with {tool_count} tools: {tools_list}")
195
+
196
+ def print_tool_header(
197
+ self,
198
+ tool_name: str,
199
+ action: str,
200
+ ) -> None:
201
+ """Print the header for a tool's output.
202
+
203
+ Args:
204
+ tool_name: str: The name of the tool.
205
+ action: str: The action being performed (e.g., 'check', 'fmt').
206
+ """
207
+ emoji: str = get_tool_emoji(tool_name)
208
+ emojis: str = (emoji + " ") * 5
209
+
210
+ border: str = "=" * BORDER_LENGTH
211
+ header: str = f"✨ Running {tool_name} ({action}) {emojis}"
212
+
213
+ self.console_output(text="")
214
+ self.console_output(text=border)
215
+ self.console_output(text=header)
216
+ self.console_output(text=border)
217
+ self.console_output(text="")
218
+
219
+ logger.debug(f"Starting tool: {tool_name}")
220
+
221
+ def print_tool_result(
222
+ self,
223
+ tool_name: str,
224
+ output: str,
225
+ issues_count: int,
226
+ raw_output_for_meta: str | None = None,
227
+ action: str = "check",
228
+ ) -> None:
229
+ """Print the result for a tool.
230
+
231
+ Args:
232
+ tool_name: str: The name of the tool.
233
+ output: str: The output from the tool.
234
+ issues_count: int: The number of issues found.
235
+ raw_output_for_meta: str | None: Raw tool output used to extract
236
+ fixable/remaining hints when available.
237
+ action: str: The action being performed ("check" or "fmt").
238
+ """
239
+ if output and output.strip():
240
+ # Display the output (either raw or formatted, depending on what was passed)
241
+ self.console_output(text=output)
242
+ logger.debug(f"Tool {tool_name} output: {len(output)} characters")
243
+ else:
244
+ logger.debug(f"Tool {tool_name} produced no output")
245
+
246
+ # Print result status
247
+ if issues_count == 0:
248
+ # For format action, prefer consolidated fixed summary if present
249
+ if action == "fmt" and output and output.strip():
250
+ # If output contains a consolidated fixed count, surface it
251
+ m_fixed = re.search(r"Fixed (\d+) issue\(s\)", output)
252
+ m_remaining = re.search(
253
+ r"Found (\d+) issue\(s\) that cannot be auto-fixed",
254
+ output,
255
+ )
256
+ fixed_val = int(m_fixed.group(1)) if m_fixed else 0
257
+ remaining_val = int(m_remaining.group(1)) if m_remaining else 0
258
+ if fixed_val > 0 or remaining_val > 0:
259
+ if fixed_val > 0:
260
+ self.console_output(text=f"✓ {fixed_val} fixed", color="green")
261
+ if remaining_val > 0:
262
+ self.console_output(
263
+ text=f"✗ {remaining_val} remaining",
264
+ color="red",
265
+ )
266
+ return
267
+
268
+ # Check if the output indicates no files were processed
269
+ if output and any(
270
+ (msg in output for msg in ["No files to", "No Python files found to"]),
271
+ ):
272
+ self.console_output(
273
+ text=("⚠️ No files processed (excluded by patterns)"),
274
+ )
275
+ else:
276
+ # For format operations, check if there are remaining issues that
277
+ # couldn't be auto-fixed
278
+ if output and "cannot be auto-fixed" in output.lower():
279
+ # Don't show "No issues found" if there are remaining issues
280
+ pass
281
+ else:
282
+ self.success(message="✓ No issues found.")
283
+ else:
284
+ # For format operations, parse the output to show better messages
285
+ if output and ("Fixed" in output or "issue(s)" in output):
286
+ # This is a format operation - parse for better messaging
287
+ # Prefer standardized counters if present in the output object
288
+ fixed_count: int = (
289
+ getattr(output, "fixed_issues_count", None)
290
+ if hasattr(output, "fixed_issues_count")
291
+ else None
292
+ )
293
+ remaining_count: int = (
294
+ getattr(output, "remaining_issues_count", None)
295
+ if hasattr(output, "remaining_issues_count")
296
+ else None
297
+ )
298
+ initial_count: int = (
299
+ getattr(output, "initial_issues_count", None)
300
+ if hasattr(output, "initial_issues_count")
301
+ else None
302
+ )
303
+
304
+ # Fallback to regex parsing when standardized counts are not available
305
+ if fixed_count is None:
306
+ fixed_match = re.search(r"Fixed (\d+) issue\(s\)", output)
307
+ fixed_count = int(fixed_match.group(1)) if fixed_match else 0
308
+ if remaining_count is None:
309
+ remaining_match = re.search(
310
+ r"Found (\d+) issue\(s\) that cannot be auto-fixed",
311
+ output,
312
+ )
313
+ remaining_count = (
314
+ int(remaining_match.group(1)) if remaining_match else 0
315
+ )
316
+ if initial_count is None:
317
+ initial_match = re.search(r"Found (\d+) errors?", output)
318
+ initial_count = int(initial_match.group(1)) if initial_match else 0
319
+
320
+ if fixed_count > 0 and remaining_count == 0:
321
+ self.success(message=f"✓ {fixed_count} fixed")
322
+ elif fixed_count > 0 and remaining_count > 0:
323
+ self.console_output(text=f"✓ {fixed_count} fixed", color="green")
324
+ self.console_output(
325
+ text=f"✗ {remaining_count} remaining", color="red"
326
+ )
327
+ elif remaining_count > 0:
328
+ self.console_output(
329
+ text=f"✗ {remaining_count} remaining", color="red"
330
+ )
331
+ elif initial_count > 0:
332
+ # If we found initial issues but no specific fixed/remaining counts,
333
+ # show the initial count as found
334
+ self.console_output(
335
+ text=f"✗ Found {initial_count} issues", color="red"
336
+ )
337
+ else:
338
+ # Fallback to original behavior
339
+ error_msg: str = f"✗ Found {issues_count} issues"
340
+ self.console_output(text=error_msg, color="red")
341
+ else:
342
+ # Show issue count with action-aware phrasing
343
+ if action == "fmt":
344
+ error_msg: str = f"✗ {issues_count} issue(s) cannot be auto-fixed"
345
+ else:
346
+ error_msg = f"✗ Found {issues_count} issues"
347
+ self.console_output(text=error_msg, color="red")
348
+
349
+ # Check if there are fixable issues and show warning
350
+ raw_text = (
351
+ raw_output_for_meta if raw_output_for_meta is not None else output
352
+ )
353
+ # Sum all fixable counts if multiple sections are present
354
+ if raw_text and action != "fmt":
355
+ # Sum any reported fixable lint issues
356
+ matches = re.findall(r"\[\*\]\s+(\d+)\s+fixable", raw_text)
357
+ fixable_count: int = sum(int(m) for m in matches) if matches else 0
358
+ # Add formatting issues as fixable by fmt when ruff reports them
359
+ if tool_name == "ruff" and (
360
+ "Formatting issues:" in raw_text or "Would reformat" in raw_text
361
+ ):
362
+ # Count files listed in 'Would reformat:' lines
363
+ reformat_files = re.findall(r"Would reformat:\s+(.+)", raw_text)
364
+ fixable_count += len(reformat_files)
365
+ # Or try summary line like: "N files would be reformatted"
366
+ if fixable_count == 0:
367
+ m_sum = re.search(
368
+ r"(\d+)\s+file(?:s)?\s+would\s+be\s+reformatted",
369
+ raw_text,
370
+ )
371
+ if m_sum:
372
+ fixable_count += int(m_sum.group(1))
373
+
374
+ if fixable_count > 0:
375
+ hint_a: str = "💡 "
376
+ hint_b: str = (
377
+ f"{fixable_count} formatting/linting issue(s) "
378
+ "can be auto-fixed "
379
+ )
380
+ hint_c: str = "with `lintro format`"
381
+ self.console_output(
382
+ text=hint_a + hint_b + hint_c,
383
+ color="yellow",
384
+ )
385
+
386
+ # Remove redundant tip; consolidated above as a single auto-fix message
387
+
388
+ self.console_output(text="") # Blank line after each tool
389
+
390
+ def print_execution_summary(
391
+ self,
392
+ action: str,
393
+ tool_results: list[object],
394
+ ) -> None:
395
+ """Print the execution summary for all tools.
396
+
397
+ Args:
398
+ action: str: The action being performed ("check" or "fmt").
399
+ tool_results: list[object]: The list of tool results.
400
+ """
401
+ # Execution summary section
402
+ summary_header: str = click.style("📋 EXECUTION SUMMARY", fg="cyan", bold=True)
403
+ border_line: str = click.style("=" * 50, fg="cyan")
404
+
405
+ self.console_output(text=summary_header)
406
+ self.console_output(text=border_line)
407
+
408
+ # Build summary table
409
+ self._print_summary_table(action=action, tool_results=tool_results)
410
+
411
+ # Final status and ASCII art
412
+ if action == "fmt":
413
+ # For format commands, track both fixed and remaining issues
414
+ # Use standardized counts when provided by tools
415
+ total_fixed: int = 0
416
+ total_remaining: int = 0
417
+ for result in tool_results:
418
+ fixed_std = getattr(result, "fixed_issues_count", None)
419
+ remaining_std = getattr(result, "remaining_issues_count", None)
420
+ if fixed_std is not None:
421
+ total_fixed += fixed_std
422
+ else:
423
+ total_fixed += getattr(result, "issues_count", 0)
424
+
425
+ if remaining_std is not None:
426
+ total_remaining += remaining_std
427
+ else:
428
+ # Fallback to parsing when standardized remaining isn't provided
429
+ output = getattr(result, "output", "")
430
+ if output and (
431
+ "remaining" in output.lower()
432
+ or "cannot be auto-fixed" in output.lower()
433
+ ):
434
+ remaining_match = RE_CANNOT_AUTOFIX.search(output)
435
+ if not remaining_match:
436
+ remaining_match = RE_REMAINING_OR_CANNOT.search(
437
+ output.lower(),
438
+ )
439
+ if remaining_match:
440
+ total_remaining += int(remaining_match.group(1))
441
+ elif not getattr(result, "success", True):
442
+ total_remaining += DEFAULT_REMAINING_COUNT
443
+
444
+ # Show ASCII art as the last item; no status text after art
445
+ self._print_ascii_art_format(total_remaining=total_remaining)
446
+ logger.debug(
447
+ f"{action} completed with {total_fixed} fixed, "
448
+ f"{total_remaining} remaining",
449
+ )
450
+ else:
451
+ # For check commands, use total issues
452
+ total_issues: int = sum(
453
+ (getattr(result, "issues_count", 0) for result in tool_results),
454
+ )
455
+ # Show ASCII art as the last item; no status text after art
456
+ self._print_ascii_art(total_issues=total_issues)
457
+ logger.debug(f"{action} completed with {total_issues} total issues")
458
+
459
+ def _print_summary_table(
460
+ self,
461
+ action: str,
462
+ tool_results: list[object],
463
+ ) -> None:
464
+ """Print the summary table for the run.
465
+
466
+ Args:
467
+ action: str: The action being performed.
468
+ tool_results: list[object]: The list of tool results.
469
+ """
470
+ try:
471
+ from tabulate import tabulate
472
+
473
+ summary_data: list[list[str]] = []
474
+ for result in tool_results:
475
+ tool_name: str = getattr(result, "name", "unknown")
476
+ issues_count: int = getattr(result, "issues_count", 0)
477
+ success: bool = getattr(result, "success", True)
478
+
479
+ emoji: str = get_tool_emoji(tool_name)
480
+ tool_display: str = f"{emoji} {tool_name}"
481
+
482
+ # For format operations, success means tool ran
483
+ # (regardless of fixes made)
484
+ # For check operations, success means no issues found
485
+ if action == "fmt":
486
+ # Format operations: show fixed count and remaining status
487
+ if success:
488
+ status_display: str = click.style(
489
+ "✅ PASS", fg="green", bold=True
490
+ )
491
+ else:
492
+ status_display = click.style("❌ FAIL", fg="red", bold=True)
493
+
494
+ # Check if files were excluded
495
+ result_output: str = getattr(result, "output", "")
496
+ if result_output and any(
497
+ (
498
+ msg in result_output
499
+ for msg in ["No files to", "No Python files found to"]
500
+ ),
501
+ ):
502
+ fixed_display: str = click.style(
503
+ "SKIPPED", fg="yellow", bold=True
504
+ )
505
+ remaining_display: str = click.style(
506
+ "SKIPPED",
507
+ fg="yellow",
508
+ bold=True,
509
+ )
510
+ else:
511
+ # Prefer standardized counts from ToolResult
512
+ remaining_std = getattr(result, "remaining_issues_count", None)
513
+ fixed_std = getattr(result, "fixed_issues_count", None)
514
+
515
+ if remaining_std is not None:
516
+ remaining_count: int = int(remaining_std)
517
+ else:
518
+ # Parse output to determine remaining issues
519
+ remaining_count = 0
520
+ if result_output and (
521
+ "remaining" in result_output.lower()
522
+ or "cannot be auto-fixed" in result_output.lower()
523
+ ):
524
+ # Try multiple patterns to match different
525
+ # output formats
526
+ remaining_match = RE_CANNOT_AUTOFIX.search(
527
+ result_output,
528
+ )
529
+ if not remaining_match:
530
+ remaining_match = RE_REMAINING_OR_CANNOT.search(
531
+ result_output.lower(),
532
+ )
533
+ if remaining_match:
534
+ remaining_count = int(remaining_match.group(1))
535
+ elif not success:
536
+ remaining_count = DEFAULT_REMAINING_COUNT
537
+
538
+ if fixed_std is not None:
539
+ fixed_display_value = int(fixed_std)
540
+ else:
541
+ # Fall back to issues_count when fixed is unknown
542
+ fixed_display_value = int(issues_count)
543
+
544
+ # Fixed issues display
545
+ fixed_display = click.style(
546
+ str(fixed_display_value),
547
+ fg="green",
548
+ bold=True,
549
+ )
550
+
551
+ # Remaining issues display
552
+ remaining_display = click.style(
553
+ str(remaining_count),
554
+ fg="red" if remaining_count > 0 else "green",
555
+ bold=True,
556
+ )
557
+ else: # check
558
+ status_display = (
559
+ click.style("✅ PASS", fg="green", bold=True)
560
+ if issues_count == 0
561
+ else click.style("❌ FAIL", fg="red", bold=True)
562
+ )
563
+ # Check if files were excluded
564
+ result_output = getattr(result, "output", "")
565
+ if result_output and any(
566
+ (
567
+ msg in result_output
568
+ for msg in ["No files to", "No Python files found to"]
569
+ ),
570
+ ):
571
+ issues_display: str = click.style(
572
+ "SKIPPED", fg="yellow", bold=True
573
+ )
574
+ else:
575
+ issues_display = click.style(
576
+ str(issues_count),
577
+ fg="green" if issues_count == 0 else "red",
578
+ bold=True,
579
+ )
580
+
581
+ if action == "fmt":
582
+ summary_data.append(
583
+ [
584
+ tool_display,
585
+ status_display,
586
+ fixed_display,
587
+ remaining_display,
588
+ ],
589
+ )
590
+ else:
591
+ summary_data.append([tool_display, status_display, issues_display])
592
+
593
+ # Set headers based on action
594
+ # Use plain headers to avoid ANSI/emojis width misalignment
595
+ headers: list[str]
596
+ if action == "fmt":
597
+ headers = ["Tool", "Status", "Fixed", "Remaining"]
598
+ else:
599
+ headers = ["Tool", "Status", "Issues"]
600
+
601
+ # Render with plain values to ensure proper alignment across terminals
602
+ table: str = tabulate(
603
+ tabular_data=summary_data,
604
+ headers=headers,
605
+ tablefmt="grid",
606
+ stralign="left",
607
+ disable_numparse=True,
608
+ )
609
+ self.console_output(text=table)
610
+ self.console_output(text="")
611
+
612
+ except ImportError:
613
+ # Fallback if tabulate not available
614
+ self.console_output(text="Summary table requires tabulate package")
615
+ logger.warning("tabulate not available for summary table")
616
+
617
+ def _print_final_status(
618
+ self,
619
+ action: str,
620
+ total_issues: int,
621
+ ) -> None:
622
+ """Print the final status for the run.
623
+
624
+ Args:
625
+ action: str: The action being performed.
626
+ total_issues: int: The total number of issues found.
627
+ """
628
+ if action == "fmt":
629
+ # Format operations: show success regardless of fixes made
630
+ if total_issues == 0:
631
+ final_msg: str = "✓ No issues found."
632
+ else:
633
+ final_msg = f"✓ Fixed {total_issues} issues."
634
+ self.console_output(text=click.style(final_msg, fg="green", bold=True))
635
+ else: # check
636
+ # Check operations: show failure if issues found
637
+ if total_issues == 0:
638
+ final_msg = "✓ No issues found."
639
+ self.console_output(text=click.style(final_msg, fg="green", bold=True))
640
+ else:
641
+ final_msg = f"✗ Found {total_issues} issues"
642
+ self.console_output(text=click.style(final_msg, fg="red", bold=True))
643
+
644
+ self.console_output(text="")
645
+
646
+ def _print_final_status_format(
647
+ self,
648
+ total_fixed: int,
649
+ total_remaining: int,
650
+ ) -> None:
651
+ """Print the final status for format operations.
652
+
653
+ Args:
654
+ total_fixed: int: The total number of issues fixed.
655
+ total_remaining: int: The total number of remaining issues.
656
+ """
657
+ if total_remaining == 0:
658
+ if total_fixed == 0:
659
+ final_msg: str = "✓ No issues found."
660
+ else:
661
+ final_msg = f"✓ {total_fixed} fixed"
662
+ self.console_output(text=click.style(final_msg, fg="green", bold=True))
663
+ else:
664
+ if total_fixed > 0:
665
+ fixed_msg: str = f"✓ {total_fixed} fixed"
666
+ self.console_output(text=click.style(fixed_msg, fg="green", bold=True))
667
+ remaining_msg: str = f"✗ {total_remaining} remaining"
668
+ self.console_output(text=click.style(remaining_msg, fg="red", bold=True))
669
+
670
+ self.console_output(text="")
671
+
672
+ def _print_ascii_art_format(
673
+ self,
674
+ total_remaining: int,
675
+ ) -> None:
676
+ """Print ASCII art for format operations based on remaining issues.
677
+
678
+ Args:
679
+ total_remaining: int: The total number of remaining issues.
680
+ """
681
+ try:
682
+ if total_remaining == 0:
683
+ ascii_art = read_ascii_art(filename="success.txt")
684
+ else:
685
+ ascii_art = read_ascii_art(filename="fail.txt")
686
+
687
+ if ascii_art:
688
+ art_text: str = "\n".join(ascii_art)
689
+ self.console_output(text=art_text)
690
+ except Exception as e:
691
+ logger.debug(f"Could not load ASCII art: {e}")
692
+
693
+ def _print_ascii_art(
694
+ self,
695
+ total_issues: int,
696
+ ) -> None:
697
+ """Print ASCII art based on the number of issues.
698
+
699
+ Args:
700
+ total_issues: int: The total number of issues found.
701
+ """
702
+ try:
703
+ if total_issues == 0:
704
+ ascii_art = read_ascii_art(filename="success.txt")
705
+ else:
706
+ ascii_art = read_ascii_art(filename="fail.txt")
707
+
708
+ if ascii_art:
709
+ art_text: str = "\n".join(ascii_art)
710
+ self.console_output(text=art_text)
711
+ except Exception as e:
712
+ logger.debug(f"Could not load ASCII art: {e}")
713
+
714
+ def print_verbose_info(
715
+ self,
716
+ action: str,
717
+ tools_list: str,
718
+ paths_list: str,
719
+ output_format: str,
720
+ ) -> None:
721
+ """Print verbose information about the run.
722
+
723
+ Args:
724
+ action: str: The action being performed.
725
+ tools_list: str: The list of tools being run.
726
+ paths_list: str: The list of paths being checked/formatted.
727
+ output_format: str: The output format being used.
728
+ """
729
+ if not self.verbose:
730
+ return
731
+
732
+ info_border: str = "=" * INFO_BORDER_LENGTH
733
+ info_title: str = (
734
+ "🔧 Format Configuration" if action == "fmt" else "🔍 Check Configuration"
735
+ )
736
+ info_emojis: str = ("🔧 " if action == "fmt" else "🔍 ") * 5
737
+
738
+ self.console_output(text=info_border)
739
+ self.console_output(text=f"{info_title} {info_emojis}")
740
+ self.console_output(text=info_border)
741
+ self.console_output(text="")
742
+
743
+ self.console_output(text=f"🔧 Running tools: {tools_list}")
744
+ self.console_output(
745
+ text=(
746
+ f"📁 {'Formatting' if action == 'fmt' else 'Checking'} "
747
+ f"paths: {paths_list}"
748
+ ),
749
+ )
750
+ self.console_output(text=f"📊 Output format: {output_format}")
751
+ self.console_output(text="")
752
+
753
+ def save_console_log(
754
+ self,
755
+ ) -> None:
756
+ """Save tracked console messages to console.log."""
757
+ console_log_path: Path = self.run_dir / "console.log"
758
+ with open(console_log_path, "w", encoding="utf-8") as f:
759
+ for message in self.console_messages:
760
+ f.write(f"{message}\n")
761
+ logger.debug(f"Saved console output to {console_log_path}")
762
+
763
+
764
+ def create_logger(
765
+ run_dir: Path,
766
+ verbose: bool = False,
767
+ raw_output: bool = False,
768
+ ) -> SimpleLintroLogger:
769
+ """Create a SimpleLintroLogger instance.
770
+
771
+ Args:
772
+ run_dir: Path: Directory for log files.
773
+ verbose: bool: Whether to enable verbose logging.
774
+ raw_output: bool: Whether to show raw tool output instead of formatted output.
775
+
776
+ Returns:
777
+ SimpleLintroLogger: Configured SimpleLintroLogger instance.
778
+ """
779
+ return SimpleLintroLogger(
780
+ run_dir=run_dir,
781
+ verbose=verbose,
782
+ raw_output=raw_output,
783
+ )