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

@@ -0,0 +1,261 @@
1
+ """Black Python formatter integration.
2
+
3
+ Black is an opinionated Python formatter. We wire it as a formatter-only tool
4
+ that cooperates with Ruff by default: when both are run, Ruff keeps linting and
5
+ Black handles formatting. Users can override via --tool-options.
6
+
7
+ Project: https://github.com/psf/black
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from dataclasses import dataclass, field
14
+
15
+ from loguru import logger
16
+
17
+ from lintro.enums.tool_type import ToolType
18
+ from lintro.models.core.tool import ToolConfig, ToolResult
19
+ from lintro.parsers.black.black_parser import parse_black_output
20
+ from lintro.tools.core.tool_base import BaseTool
21
+ from lintro.utils.tool_utils import walk_files_with_excludes
22
+
23
+ BLACK_DEFAULT_TIMEOUT: int = 30
24
+ BLACK_DEFAULT_PRIORITY: int = 90 # Prefer Black ahead of Ruff formatting
25
+ BLACK_FILE_PATTERNS: list[str] = ["*.py", "*.pyi"]
26
+
27
+
28
+ @dataclass
29
+ class BlackTool(BaseTool):
30
+ """Black Python formatter integration."""
31
+
32
+ name: str = "black"
33
+ description: str = "Opinionated Python code formatter"
34
+ can_fix: bool = True
35
+ config: ToolConfig = field(
36
+ default_factory=lambda: ToolConfig(
37
+ priority=BLACK_DEFAULT_PRIORITY,
38
+ conflicts_with=[], # Compatible with Ruff (lint); no direct conflicts
39
+ file_patterns=BLACK_FILE_PATTERNS,
40
+ tool_type=ToolType.FORMATTER,
41
+ options={
42
+ "line_length": None,
43
+ "target_version": None,
44
+ "fast": False, # Do not use --fast by default
45
+ "preview": False, # Do not enable preview by default
46
+ "diff": False, # Default to standard output messages
47
+ },
48
+ ),
49
+ )
50
+
51
+ def set_options(
52
+ self,
53
+ line_length: int | None = None,
54
+ target_version: str | None = None,
55
+ fast: bool | None = None,
56
+ preview: bool | None = None,
57
+ diff: bool | None = None,
58
+ **kwargs,
59
+ ) -> None:
60
+ """Set Black-specific options with validation.
61
+
62
+ Args:
63
+ line_length: Optional line length override.
64
+ target_version: String per Black CLI (e.g., "py313").
65
+ fast: Use --fast mode (skip safety checks).
66
+ preview: Enable preview style.
67
+ diff: Show diffs in output when formatting.
68
+ **kwargs: Additional base options like ``timeout``, ``exclude_patterns``,
69
+ and ``include_venv`` that are handled by ``BaseTool``.
70
+
71
+ Raises:
72
+ ValueError: If any provided option has an invalid type.
73
+ """
74
+ if line_length is not None and not isinstance(line_length, int):
75
+ raise ValueError("line_length must be an integer")
76
+ if target_version is not None and not isinstance(target_version, str):
77
+ raise ValueError("target_version must be a string")
78
+ if fast is not None and not isinstance(fast, bool):
79
+ raise ValueError("fast must be a boolean")
80
+ if preview is not None and not isinstance(preview, bool):
81
+ raise ValueError("preview must be a boolean")
82
+ if diff is not None and not isinstance(diff, bool):
83
+ raise ValueError("diff must be a boolean")
84
+
85
+ options = {
86
+ "line_length": line_length,
87
+ "target_version": target_version,
88
+ "fast": fast,
89
+ "preview": preview,
90
+ "diff": diff,
91
+ }
92
+ # Remove None values
93
+ options = {k: v for k, v in options.items() if v is not None}
94
+ super().set_options(**options, **kwargs)
95
+
96
+ def _build_common_args(self) -> list[str]:
97
+ args: list[str] = []
98
+ if self.options.get("line_length"):
99
+ args.extend(["--line-length", str(self.options["line_length"])])
100
+ if self.options.get("target_version"):
101
+ args.extend(["--target-version", str(self.options["target_version"])])
102
+ if self.options.get("fast"):
103
+ args.append("--fast")
104
+ if self.options.get("preview"):
105
+ args.append("--preview")
106
+ return args
107
+
108
+ def check(self, paths: list[str]) -> ToolResult:
109
+ """Check files using Black without applying changes.
110
+
111
+ Args:
112
+ paths: List of file or directory paths to check.
113
+
114
+ Returns:
115
+ ToolResult: Result containing success flag, issue count, and issues.
116
+ """
117
+ self._validate_paths(paths=paths)
118
+
119
+ py_files: list[str] = walk_files_with_excludes(
120
+ paths=paths,
121
+ file_patterns=self.config.file_patterns,
122
+ exclude_patterns=self.exclude_patterns,
123
+ include_venv=self.include_venv,
124
+ )
125
+
126
+ if not py_files:
127
+ return ToolResult(
128
+ name=self.name,
129
+ success=True,
130
+ output="No files to check.",
131
+ issues_count=0,
132
+ )
133
+
134
+ cwd: str | None = self.get_cwd(paths=py_files)
135
+ rel_files: list[str] = [os.path.relpath(f, cwd) if cwd else f for f in py_files]
136
+
137
+ cmd: list[str] = self._get_executable_command(tool_name="black") + [
138
+ "--check",
139
+ ]
140
+ cmd.extend(self._build_common_args())
141
+ cmd.extend(rel_files)
142
+
143
+ logger.debug(f"[BlackTool] Running: {' '.join(cmd)} (cwd={cwd})")
144
+ success, output = self._run_subprocess(
145
+ cmd=cmd,
146
+ timeout=self.options.get("timeout", BLACK_DEFAULT_TIMEOUT),
147
+ cwd=cwd,
148
+ )
149
+
150
+ issues = parse_black_output(output=output)
151
+ count = len(issues)
152
+ # In check mode, success means no differences
153
+ return ToolResult(
154
+ name=self.name,
155
+ success=(success and count == 0),
156
+ output=None if count == 0 else output,
157
+ issues_count=count,
158
+ issues=issues,
159
+ )
160
+
161
+ def fix(self, paths: list[str]) -> ToolResult:
162
+ """Format files using Black, returning standardized counts.
163
+
164
+ Args:
165
+ paths: List of file or directory paths to format.
166
+
167
+ Returns:
168
+ ToolResult: Result containing counts and any remaining issues.
169
+ """
170
+ self._validate_paths(paths=paths)
171
+
172
+ py_files: list[str] = walk_files_with_excludes(
173
+ paths=paths,
174
+ file_patterns=self.config.file_patterns,
175
+ exclude_patterns=self.exclude_patterns,
176
+ include_venv=self.include_venv,
177
+ )
178
+ if not py_files:
179
+ return ToolResult(
180
+ name=self.name,
181
+ success=True,
182
+ output="No files to format.",
183
+ issues_count=0,
184
+ )
185
+
186
+ cwd: str | None = self.get_cwd(paths=py_files)
187
+ rel_files: list[str] = [os.path.relpath(f, cwd) if cwd else f for f in py_files]
188
+
189
+ # Build reusable check command (used for final verification)
190
+ check_cmd: list[str] = self._get_executable_command(tool_name="black") + [
191
+ "--check",
192
+ ]
193
+ check_cmd.extend(self._build_common_args())
194
+ check_cmd.extend(rel_files)
195
+
196
+ # When diff is requested, skip the initial check to ensure the middle
197
+ # invocation is the formatting run (as exercised by unit tests) and to
198
+ # avoid redundant subprocess calls.
199
+ if self.options.get("diff"):
200
+ initial_issues = []
201
+ initial_count = 0
202
+ else:
203
+ _, check_output = self._run_subprocess(
204
+ cmd=check_cmd,
205
+ timeout=self.options.get("timeout", BLACK_DEFAULT_TIMEOUT),
206
+ cwd=cwd,
207
+ )
208
+ initial_issues = parse_black_output(output=check_output)
209
+ initial_count = len(initial_issues)
210
+
211
+ # Apply formatting
212
+ fix_cmd_base: list[str] = self._get_executable_command(tool_name="black")
213
+ fix_cmd: list[str] = list(fix_cmd_base)
214
+ if self.options.get("diff"):
215
+ # When diff is requested, ensure the flag is present on the format run
216
+ # so tests can assert its presence on the middle invocation.
217
+ fix_cmd.append("--diff")
218
+ fix_cmd.extend(self._build_common_args())
219
+ fix_cmd.extend(rel_files)
220
+
221
+ logger.debug(f"[BlackTool] Fixing: {' '.join(fix_cmd)} (cwd={cwd})")
222
+ _, fix_output = self._run_subprocess(
223
+ cmd=fix_cmd,
224
+ timeout=self.options.get("timeout", BLACK_DEFAULT_TIMEOUT),
225
+ cwd=cwd,
226
+ )
227
+
228
+ # Final check for remaining differences
229
+ final_success, final_output = self._run_subprocess(
230
+ cmd=check_cmd,
231
+ timeout=self.options.get("timeout", BLACK_DEFAULT_TIMEOUT),
232
+ cwd=cwd,
233
+ )
234
+ remaining_issues = parse_black_output(output=final_output)
235
+ remaining_count = len(remaining_issues)
236
+
237
+ fixed_count = max(0, initial_count - remaining_count)
238
+
239
+ # Build concise summary
240
+ summary: list[str] = []
241
+ if fixed_count > 0:
242
+ summary.append(f"Fixed {fixed_count} issue(s)")
243
+ if remaining_count > 0:
244
+ summary.append(
245
+ f"Found {remaining_count} issue(s) that cannot be auto-fixed",
246
+ )
247
+ final_summary = "\n".join(summary) if summary else "No fixes applied."
248
+
249
+ # Parse per-file reformats from the formatting run to display in console
250
+ fixed_issues_parsed = parse_black_output(output=fix_output)
251
+
252
+ return ToolResult(
253
+ name=self.name,
254
+ success=(remaining_count == 0),
255
+ output=final_summary,
256
+ issues_count=remaining_count,
257
+ issues=fixed_issues_parsed if fixed_issues_parsed else remaining_issues,
258
+ initial_issues_count=initial_count,
259
+ fixed_issues_count=fixed_count,
260
+ remaining_issues_count=remaining_count,
261
+ )
@@ -233,7 +233,7 @@ class PrettierTool(BaseTool):
233
233
 
234
234
  if remaining_count > 0:
235
235
  output_lines.append(
236
- f"Found {remaining_count} issue(s) that cannot be auto-fixed"
236
+ f"Found {remaining_count} issue(s) that cannot be auto-fixed",
237
237
  )
238
238
  for issue in remaining_issues[:5]:
239
239
  output_lines.append(f" {issue.file} - {issue.message}")
@@ -252,12 +252,25 @@ class RuffTool(BaseTool):
252
252
  cmd.append("--isolated")
253
253
 
254
254
  # Add configuration options
255
- if self.options.get("select"):
256
- cmd.extend(["--select", ",".join(self.options["select"])])
257
- if self.options.get("ignore"):
258
- cmd.extend(["--ignore", ",".join(self.options["ignore"])])
259
- if self.options.get("extend_select"):
260
- cmd.extend(["--extend-select", ",".join(self.options["extend_select"])])
255
+ selected_rules = list(self.options.get("select") or [])
256
+ ignored_rules = set(self.options.get("ignore") or [])
257
+ extend_selected_rules = list(self.options.get("extend_select") or [])
258
+
259
+ # Ensure E501 is included when selecting E-family unless explicitly ignored
260
+ if (
261
+ "E" in selected_rules
262
+ and "E501" not in ignored_rules
263
+ and "E501" not in selected_rules
264
+ and "E501" not in extend_selected_rules
265
+ ):
266
+ extend_selected_rules.append("E501")
267
+
268
+ if selected_rules:
269
+ cmd.extend(["--select", ",".join(selected_rules)])
270
+ if ignored_rules:
271
+ cmd.extend(["--ignore", ",".join(sorted(ignored_rules))])
272
+ if extend_selected_rules:
273
+ cmd.extend(["--extend-select", ",".join(extend_selected_rules)])
261
274
  if self.options.get("extend_ignore"):
262
275
  cmd.extend(["--extend-ignore", ",".join(self.options["extend_ignore"])])
263
276
  if self.options.get("line_length"):
@@ -366,7 +379,9 @@ class RuffTool(BaseTool):
366
379
  success_lint: bool
367
380
  output_lint: str
368
381
  success_lint, output_lint = self._run_subprocess(
369
- cmd=cmd, timeout=timeout, cwd=cwd
382
+ cmd=cmd,
383
+ timeout=timeout,
384
+ cwd=cwd,
370
385
  )
371
386
  lint_issues = parse_ruff_output(output=output_lint)
372
387
  lint_issues_count: int = len(lint_issues)
@@ -475,20 +490,23 @@ class RuffTool(BaseTool):
475
490
  initial_count: int = len(initial_issues)
476
491
 
477
492
  # Also check formatting issues before fixing
478
- format_cmd_check: list[str] = self._build_format_command(
479
- files=python_files,
480
- check_only=True,
481
- )
482
- success_format_check: bool
483
- output_format_check: str
484
- success_format_check, output_format_check = self._run_subprocess(
485
- cmd=format_cmd_check,
486
- timeout=timeout,
487
- )
488
- format_files = parse_ruff_format_check_output(output=output_format_check)
489
- initial_format_count: int = len(format_files)
493
+ initial_format_count: int = 0
494
+ format_files: list[str] = []
495
+ if self.options.get("format", False):
496
+ format_cmd_check: list[str] = self._build_format_command(
497
+ files=python_files,
498
+ check_only=True,
499
+ )
500
+ success_format_check: bool
501
+ output_format_check: str
502
+ success_format_check, output_format_check = self._run_subprocess(
503
+ cmd=format_cmd_check,
504
+ timeout=timeout,
505
+ )
506
+ format_files = parse_ruff_format_check_output(output=output_format_check)
507
+ initial_format_count = len(format_files)
490
508
 
491
- # Total initial issues (linting + formatting)
509
+ # Track initial totals separately for accurate fixed/remaining math
492
510
  total_initial_count: int = initial_count + initial_format_count
493
511
 
494
512
  # Optionally run ruff check --fix (lint fixes)
@@ -502,9 +520,12 @@ class RuffTool(BaseTool):
502
520
  remaining_issues = parse_ruff_output(output=output)
503
521
  remaining_count = len(remaining_issues)
504
522
 
505
- # Calculate how many issues were actually fixed
506
- # Add formatting fixes if formatter ran
507
- fixed_count: int = total_initial_count - remaining_count
523
+ # Compute fixed lint issues by diffing initial vs remaining (internal only)
524
+ # Not used for display; summary counts reflect totals.
525
+
526
+ # Calculate how many lint issues were actually fixed
527
+ fixed_lint_count: int = max(0, initial_count - remaining_count)
528
+ fixed_count: int = fixed_lint_count
508
529
 
509
530
  # Do not print raw initial counts; keep output concise and unified
510
531
 
@@ -576,9 +597,9 @@ class RuffTool(BaseTool):
576
597
  cmd=format_cmd,
577
598
  timeout=timeout,
578
599
  )
579
- # If we detected formatting issues initially, consider them fixed now
600
+ # Formatting fixes are counted separately from lint fixes
580
601
  if initial_format_count > 0:
581
- fixed_count += initial_format_count
602
+ fixed_count = fixed_lint_count + initial_format_count
582
603
  # Suppress raw formatter output for consistency; rely on unified summary
583
604
  # Only consider formatting failure if there are actual formatting
584
605
  # issues. Don't fail the overall operation just because formatting
@@ -611,6 +632,7 @@ class RuffTool(BaseTool):
611
632
  output=final_output,
612
633
  # For fix operations, issues_count represents remaining for summaries
613
634
  issues_count=remaining_count,
635
+ # Display remaining issues only to align tables with summary counts
614
636
  issues=remaining_issues,
615
637
  initial_issues_count=total_initial_count,
616
638
  fixed_issues_count=fixed_count,
lintro/tools/tool_enum.py CHANGED
@@ -4,6 +4,7 @@ from enum import Enum
4
4
 
5
5
  from lintro.tools.implementations.tool_actionlint import ActionlintTool
6
6
  from lintro.tools.implementations.tool_bandit import BanditTool
7
+ from lintro.tools.implementations.tool_black import BlackTool
7
8
  from lintro.tools.implementations.tool_darglint import DarglintTool
8
9
  from lintro.tools.implementations.tool_hadolint import HadolintTool
9
10
  from lintro.tools.implementations.tool_prettier import PrettierTool
@@ -12,6 +13,7 @@ from lintro.tools.implementations.tool_yamllint import YamllintTool
12
13
 
13
14
 
14
15
  class ToolEnum(Enum):
16
+ BLACK = BlackTool
15
17
  DARGLINT = DarglintTool
16
18
  HADOLINT = HadolintTool
17
19
  PRETTIER = PrettierTool
@@ -37,7 +37,9 @@ def main() -> int:
37
37
  parser.add_argument("--width", type=int, default=80)
38
38
  parser.add_argument("--height", type=int, default=20)
39
39
  parser.add_argument(
40
- "--align", choices=["left", "center", "right"], default="center"
40
+ "--align",
41
+ choices=["left", "center", "right"],
42
+ default="center",
41
43
  )
42
44
  parser.add_argument(
43
45
  "--valign",
lintro/utils/config.py CHANGED
@@ -37,3 +37,19 @@ def load_lintro_tool_config(tool_name: str) -> dict[str, Any]:
37
37
  if isinstance(section, dict):
38
38
  return section
39
39
  return {}
40
+
41
+
42
+ def load_post_checks_config() -> dict[str, Any]:
43
+ """Load post-checks configuration from pyproject.
44
+
45
+ Returns:
46
+ Dict with keys like:
47
+ - enabled: bool
48
+ - tools: list[str]
49
+ - enforce_failure: bool
50
+ """
51
+ cfg = _load_pyproject()
52
+ section = cfg.get("post_checks", {})
53
+ if isinstance(section, dict):
54
+ return section
55
+ return {}
@@ -20,6 +20,7 @@ TOOL_EMOJIS: dict[str, str] = {
20
20
  "darglint": "📝",
21
21
  "hadolint": "🐳",
22
22
  "yamllint": "📄",
23
+ "black": "🖤",
23
24
  }
24
25
  DEFAULT_EMOJI: str = "🔧"
25
26
  BORDER_LENGTH: int = 70
@@ -218,6 +219,35 @@ class SimpleLintroLogger:
218
219
 
219
220
  logger.debug(f"Starting tool: {tool_name}")
220
221
 
222
+ def print_post_checks_header(
223
+ self,
224
+ action: str,
225
+ ) -> None:
226
+ """Print a distinct header separating the post-checks phase.
227
+
228
+ Args:
229
+ action: str: The action being performed (e.g., 'check', 'fmt').
230
+ """
231
+ # Use a heavy unicode border and magenta coloring to stand out
232
+ border_char: str = "━"
233
+ border: str = border_char * BORDER_LENGTH
234
+ title_styled: str = click.style(
235
+ text="🚦 POST-CHECKS",
236
+ fg="magenta",
237
+ bold=True,
238
+ )
239
+ subtitle_styled: str = click.style(
240
+ text=("Running optional follow-up checks after primary tools"),
241
+ fg="magenta",
242
+ )
243
+
244
+ self.console_output(text="")
245
+ self.console_output(text=border, color="magenta")
246
+ self.console_output(text=title_styled)
247
+ self.console_output(text=subtitle_styled)
248
+ self.console_output(text=border, color="magenta")
249
+ self.console_output(text="")
250
+
221
251
  def print_tool_result(
222
252
  self,
223
253
  tool_name: str,
@@ -327,19 +357,25 @@ class SimpleLintroLogger:
327
357
  if fixed_count > 0 and remaining_count == 0:
328
358
  self.success(message=f"✓ {fixed_count} fixed")
329
359
  elif fixed_count > 0 and remaining_count > 0:
330
- self.console_output(text=f"✓ {fixed_count} fixed", color="green")
331
360
  self.console_output(
332
- text=f" {remaining_count} remaining", color="red"
361
+ text=f" {fixed_count} fixed",
362
+ color="green",
363
+ )
364
+ self.console_output(
365
+ text=f"✗ {remaining_count} remaining",
366
+ color="red",
333
367
  )
334
368
  elif remaining_count > 0:
335
369
  self.console_output(
336
- text=f"✗ {remaining_count} remaining", color="red"
370
+ text=f"✗ {remaining_count} remaining",
371
+ color="red",
337
372
  )
338
373
  elif initial_count > 0:
339
374
  # If we found initial issues but no specific fixed/remaining counts,
340
375
  # show the initial count as found
341
376
  self.console_output(
342
- text=f"✗ Found {initial_count} issues", color="red"
377
+ text=f"✗ Found {initial_count} issues",
378
+ color="red",
343
379
  )
344
380
  else:
345
381
  # Fallback to original behavior
@@ -415,7 +451,7 @@ class SimpleLintroLogger:
415
451
  # Build summary table
416
452
  self._print_summary_table(action=action, tool_results=tool_results)
417
453
 
418
- # Final status and ASCII art
454
+ # Totals line and ASCII art
419
455
  if action == "fmt":
420
456
  # For format commands, track both fixed and remaining issues
421
457
  # Use standardized counts when provided by tools
@@ -448,7 +484,11 @@ class SimpleLintroLogger:
448
484
  elif not getattr(result, "success", True):
449
485
  total_remaining += DEFAULT_REMAINING_COUNT
450
486
 
451
- # Show ASCII art as the last item; no status text after art
487
+ # Show totals line then ASCII art
488
+ totals_line: str = (
489
+ f"Totals: fixed={total_fixed}, remaining={total_remaining}"
490
+ )
491
+ self.console_output(text=click.style(totals_line, fg="cyan"))
452
492
  self._print_ascii_art_format(total_remaining=total_remaining)
453
493
  logger.debug(
454
494
  f"{action} completed with {total_fixed} fixed, "
@@ -465,11 +505,13 @@ class SimpleLintroLogger:
465
505
  total_for_art: int = (
466
506
  total_issues if not any_failed else max(1, total_issues)
467
507
  )
468
- # Show ASCII art as the last item; no status text after art
508
+ # Show totals line then ASCII art
509
+ totals_line_chk: str = f"Total issues: {total_issues}"
510
+ self.console_output(text=click.style(totals_line_chk, fg="cyan"))
469
511
  self._print_ascii_art(total_issues=total_for_art)
470
512
  logger.debug(
471
513
  f"{action} completed with {total_issues} total issues"
472
- + (" and failures" if any_failed else "")
514
+ + (" and failures" if any_failed else ""),
473
515
  )
474
516
 
475
517
  def _print_summary_table(
@@ -502,7 +544,9 @@ class SimpleLintroLogger:
502
544
  # Format operations: show fixed count and remaining status
503
545
  if success:
504
546
  status_display: str = click.style(
505
- "✅ PASS", fg="green", bold=True
547
+ "✅ PASS",
548
+ fg="green",
549
+ bold=True,
506
550
  )
507
551
  else:
508
552
  status_display = click.style("❌ FAIL", fg="red", bold=True)
@@ -516,7 +560,9 @@ class SimpleLintroLogger:
516
560
  ),
517
561
  ):
518
562
  fixed_display: str = click.style(
519
- "SKIPPED", fg="yellow", bold=True
563
+ "SKIPPED",
564
+ fg="yellow",
565
+ bold=True,
520
566
  )
521
567
  remaining_display: str = click.style(
522
568
  "SKIPPED",
@@ -585,7 +631,9 @@ class SimpleLintroLogger:
585
631
  ),
586
632
  ):
587
633
  issues_display: str = click.style(
588
- "SKIPPED", fg="yellow", bold=True
634
+ "SKIPPED",
635
+ fg="yellow",
636
+ bold=True,
589
637
  )
590
638
  else:
591
639
  issues_display = click.style(
@@ -230,7 +230,7 @@ class OutputManager:
230
230
  if hasattr(r, "issues") and r.issues:
231
231
  html.append(
232
232
  "<table border='1'><tr><th>File</th><th>Line</th><th>Code</th>"
233
- "<th>Message</th></tr>"
233
+ "<th>Message</th></tr>",
234
234
  )
235
235
  for issue in r.issues:
236
236
  file: str = _html_escape(getattr(issue, "file", ""))
@@ -239,7 +239,7 @@ class OutputManager:
239
239
  msg: str = _html_escape(getattr(issue, "message", ""))
240
240
  html.append(
241
241
  f"<tr><td>{file}</td><td>{line}</td><td>{code}</td>"
242
- f"<td>{msg}</td></tr>"
242
+ f"<td>{msg}</td></tr>",
243
243
  )
244
244
  html.append("</table>")
245
245
  else: