lintro 0.6.2__py3-none-any.whl → 0.17.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.
- lintro/__init__.py +1 -1
- lintro/cli.py +230 -14
- lintro/cli_utils/commands/__init__.py +8 -1
- lintro/cli_utils/commands/check.py +1 -0
- lintro/cli_utils/commands/config.py +325 -0
- lintro/cli_utils/commands/format.py +2 -2
- lintro/cli_utils/commands/init.py +361 -0
- lintro/cli_utils/commands/list_tools.py +180 -42
- lintro/cli_utils/commands/test.py +316 -0
- lintro/cli_utils/commands/versions.py +81 -0
- lintro/config/__init__.py +62 -0
- lintro/config/config_loader.py +420 -0
- lintro/config/lintro_config.py +189 -0
- lintro/config/tool_config_generator.py +403 -0
- lintro/enums/__init__.py +1 -0
- lintro/enums/darglint_strictness.py +10 -0
- lintro/enums/hadolint_enums.py +22 -0
- lintro/enums/tool_name.py +2 -0
- lintro/enums/tool_type.py +2 -0
- lintro/enums/yamllint_format.py +11 -0
- lintro/exceptions/__init__.py +1 -0
- lintro/formatters/__init__.py +1 -0
- lintro/formatters/core/__init__.py +1 -0
- lintro/formatters/core/output_style.py +11 -0
- lintro/formatters/core/table_descriptor.py +8 -0
- lintro/formatters/styles/csv.py +2 -0
- lintro/formatters/styles/grid.py +2 -0
- lintro/formatters/styles/html.py +2 -0
- lintro/formatters/styles/json.py +2 -0
- lintro/formatters/styles/markdown.py +2 -0
- lintro/formatters/styles/plain.py +2 -0
- lintro/formatters/tools/__init__.py +12 -0
- lintro/formatters/tools/black_formatter.py +27 -5
- lintro/formatters/tools/darglint_formatter.py +16 -1
- lintro/formatters/tools/eslint_formatter.py +108 -0
- lintro/formatters/tools/hadolint_formatter.py +13 -0
- lintro/formatters/tools/markdownlint_formatter.py +88 -0
- lintro/formatters/tools/prettier_formatter.py +15 -0
- lintro/formatters/tools/pytest_formatter.py +201 -0
- lintro/formatters/tools/ruff_formatter.py +26 -5
- lintro/formatters/tools/yamllint_formatter.py +14 -1
- lintro/models/__init__.py +1 -0
- lintro/models/core/__init__.py +1 -0
- lintro/models/core/tool_config.py +11 -7
- lintro/parsers/__init__.py +69 -9
- lintro/parsers/actionlint/actionlint_parser.py +1 -1
- lintro/parsers/bandit/__init__.py +6 -0
- lintro/parsers/bandit/bandit_issue.py +49 -0
- lintro/parsers/bandit/bandit_parser.py +99 -0
- lintro/parsers/black/black_issue.py +4 -0
- lintro/parsers/darglint/__init__.py +1 -0
- lintro/parsers/darglint/darglint_issue.py +11 -0
- lintro/parsers/eslint/__init__.py +6 -0
- lintro/parsers/eslint/eslint_issue.py +26 -0
- lintro/parsers/eslint/eslint_parser.py +63 -0
- lintro/parsers/markdownlint/__init__.py +6 -0
- lintro/parsers/markdownlint/markdownlint_issue.py +22 -0
- lintro/parsers/markdownlint/markdownlint_parser.py +113 -0
- lintro/parsers/prettier/__init__.py +1 -0
- lintro/parsers/prettier/prettier_issue.py +12 -0
- lintro/parsers/prettier/prettier_parser.py +1 -1
- lintro/parsers/pytest/__init__.py +21 -0
- lintro/parsers/pytest/pytest_issue.py +28 -0
- lintro/parsers/pytest/pytest_parser.py +483 -0
- lintro/parsers/ruff/ruff_parser.py +6 -2
- lintro/parsers/yamllint/__init__.py +1 -0
- lintro/tools/__init__.py +3 -1
- lintro/tools/core/__init__.py +1 -0
- lintro/tools/core/timeout_utils.py +112 -0
- lintro/tools/core/tool_base.py +286 -50
- lintro/tools/core/tool_manager.py +77 -24
- lintro/tools/core/version_requirements.py +482 -0
- lintro/tools/implementations/__init__.py +1 -0
- lintro/tools/implementations/pytest/pytest_command_builder.py +311 -0
- lintro/tools/implementations/pytest/pytest_config.py +200 -0
- lintro/tools/implementations/pytest/pytest_error_handler.py +128 -0
- lintro/tools/implementations/pytest/pytest_executor.py +122 -0
- lintro/tools/implementations/pytest/pytest_handlers.py +375 -0
- lintro/tools/implementations/pytest/pytest_option_validators.py +212 -0
- lintro/tools/implementations/pytest/pytest_output_processor.py +408 -0
- lintro/tools/implementations/pytest/pytest_result_processor.py +113 -0
- lintro/tools/implementations/pytest/pytest_utils.py +697 -0
- lintro/tools/implementations/tool_actionlint.py +106 -16
- lintro/tools/implementations/tool_bandit.py +34 -29
- lintro/tools/implementations/tool_black.py +236 -29
- lintro/tools/implementations/tool_darglint.py +183 -22
- lintro/tools/implementations/tool_eslint.py +374 -0
- lintro/tools/implementations/tool_hadolint.py +94 -25
- lintro/tools/implementations/tool_markdownlint.py +354 -0
- lintro/tools/implementations/tool_prettier.py +317 -24
- lintro/tools/implementations/tool_pytest.py +327 -0
- lintro/tools/implementations/tool_ruff.py +278 -84
- lintro/tools/implementations/tool_yamllint.py +448 -34
- lintro/tools/tool_enum.py +8 -0
- lintro/utils/__init__.py +1 -0
- lintro/utils/ascii_normalize_cli.py +5 -0
- lintro/utils/config.py +41 -18
- lintro/utils/console_logger.py +211 -25
- lintro/utils/path_utils.py +42 -0
- lintro/utils/tool_executor.py +339 -45
- lintro/utils/tool_utils.py +51 -24
- lintro/utils/unified_config.py +926 -0
- {lintro-0.6.2.dist-info → lintro-0.17.2.dist-info}/METADATA +172 -30
- lintro-0.17.2.dist-info/RECORD +134 -0
- lintro-0.6.2.dist-info/RECORD +0 -96
- {lintro-0.6.2.dist-info → lintro-0.17.2.dist-info}/WHEEL +0 -0
- {lintro-0.6.2.dist-info → lintro-0.17.2.dist-info}/entry_points.txt +0 -0
- {lintro-0.6.2.dist-info → lintro-0.17.2.dist-info}/licenses/LICENSE +0 -0
- {lintro-0.6.2.dist-info → lintro-0.17.2.dist-info}/top_level.txt +0 -0
|
@@ -7,9 +7,11 @@ structured issues, and returns a normalized `ToolResult`.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import contextlib
|
|
10
11
|
import subprocess # nosec B404 - used safely with shell disabled
|
|
11
12
|
from dataclasses import dataclass, field
|
|
12
13
|
|
|
14
|
+
import click
|
|
13
15
|
from loguru import logger
|
|
14
16
|
|
|
15
17
|
from lintro.enums.tool_type import ToolType
|
|
@@ -66,6 +68,55 @@ class ActionlintTool(BaseTool):
|
|
|
66
68
|
"""
|
|
67
69
|
return ["actionlint"]
|
|
68
70
|
|
|
71
|
+
def _process_file(
|
|
72
|
+
self,
|
|
73
|
+
file_path: str,
|
|
74
|
+
base_cmd: list[str],
|
|
75
|
+
timeout: int,
|
|
76
|
+
all_outputs: list[str],
|
|
77
|
+
all_issues: list,
|
|
78
|
+
skipped_files: list[str],
|
|
79
|
+
all_success: bool,
|
|
80
|
+
execution_failures: int,
|
|
81
|
+
) -> tuple[bool, int]:
|
|
82
|
+
"""Process a single file with actionlint.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
file_path: Path to the file to process.
|
|
86
|
+
base_cmd: Base command list for actionlint.
|
|
87
|
+
timeout: Timeout in seconds.
|
|
88
|
+
all_outputs: List to append raw output to.
|
|
89
|
+
all_issues: List to extend with parsed issues.
|
|
90
|
+
skipped_files: List to append skipped file paths to.
|
|
91
|
+
all_success: Current success flag.
|
|
92
|
+
execution_failures: Current execution failures count.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Tuple of (updated_all_success, updated_execution_failures).
|
|
96
|
+
"""
|
|
97
|
+
cmd = base_cmd + [file_path]
|
|
98
|
+
try:
|
|
99
|
+
success, output = self._run_subprocess(cmd=cmd, timeout=timeout)
|
|
100
|
+
issues = parse_actionlint_output(output)
|
|
101
|
+
if not success:
|
|
102
|
+
all_success = False
|
|
103
|
+
# Preserve output when subprocess fails even if parsing yields no issues
|
|
104
|
+
if output and (issues or not success):
|
|
105
|
+
all_outputs.append(output)
|
|
106
|
+
if issues:
|
|
107
|
+
all_issues.extend(issues)
|
|
108
|
+
except subprocess.TimeoutExpired:
|
|
109
|
+
skipped_files.append(file_path)
|
|
110
|
+
all_success = False
|
|
111
|
+
# Count timeout as an execution failure
|
|
112
|
+
execution_failures += 1
|
|
113
|
+
except Exception as e: # pragma: no cover
|
|
114
|
+
all_success = False
|
|
115
|
+
all_outputs.append(f"Error checking {file_path}: {e}")
|
|
116
|
+
# Count execution errors as failures
|
|
117
|
+
execution_failures += 1
|
|
118
|
+
return all_success, execution_failures
|
|
119
|
+
|
|
69
120
|
def check(self, paths: list[str]) -> ToolResult:
|
|
70
121
|
"""Check GitHub Actions workflow files with actionlint.
|
|
71
122
|
|
|
@@ -76,6 +127,11 @@ class ActionlintTool(BaseTool):
|
|
|
76
127
|
A `ToolResult` containing success status, aggregated output (if any),
|
|
77
128
|
issue count, and parsed issues.
|
|
78
129
|
"""
|
|
130
|
+
# Check version requirements
|
|
131
|
+
version_result = self._verify_tool_version()
|
|
132
|
+
if version_result is not None:
|
|
133
|
+
return version_result
|
|
134
|
+
|
|
79
135
|
self._validate_paths(paths=paths)
|
|
80
136
|
if not paths:
|
|
81
137
|
return ToolResult(
|
|
@@ -111,26 +167,60 @@ class ActionlintTool(BaseTool):
|
|
|
111
167
|
all_outputs: list[str] = []
|
|
112
168
|
all_issues = []
|
|
113
169
|
all_success = True
|
|
170
|
+
execution_failures: int = 0
|
|
114
171
|
|
|
172
|
+
skipped_files: list[str] = []
|
|
115
173
|
base_cmd = self._build_command()
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
174
|
+
|
|
175
|
+
# Show progress bar only when processing multiple files
|
|
176
|
+
if len(workflow_files) >= 2:
|
|
177
|
+
files_to_iterate = click.progressbar(
|
|
178
|
+
workflow_files,
|
|
179
|
+
label="Processing files",
|
|
180
|
+
bar_template="%(label)s %(info)s",
|
|
181
|
+
)
|
|
182
|
+
context_mgr = files_to_iterate
|
|
183
|
+
else:
|
|
184
|
+
files_to_iterate = workflow_files
|
|
185
|
+
context_mgr = contextlib.nullcontext()
|
|
186
|
+
|
|
187
|
+
with context_mgr:
|
|
188
|
+
for file_path in files_to_iterate:
|
|
189
|
+
all_success, execution_failures = self._process_file(
|
|
190
|
+
file_path=file_path,
|
|
191
|
+
base_cmd=base_cmd,
|
|
192
|
+
timeout=timeout,
|
|
193
|
+
all_outputs=all_outputs,
|
|
194
|
+
all_issues=all_issues,
|
|
195
|
+
skipped_files=skipped_files,
|
|
196
|
+
all_success=all_success,
|
|
197
|
+
execution_failures=execution_failures,
|
|
198
|
+
)
|
|
132
199
|
|
|
133
200
|
combined_output = "\n".join(all_outputs) if all_outputs else None
|
|
201
|
+
if skipped_files:
|
|
202
|
+
timeout_msg = (
|
|
203
|
+
f"Skipped {len(skipped_files)} file(s) due to timeout "
|
|
204
|
+
f"({timeout}s limit exceeded):"
|
|
205
|
+
)
|
|
206
|
+
for file in skipped_files:
|
|
207
|
+
timeout_msg += f"\n - {file}"
|
|
208
|
+
if combined_output:
|
|
209
|
+
combined_output = f"{combined_output}\n\n{timeout_msg}"
|
|
210
|
+
else:
|
|
211
|
+
combined_output = timeout_msg
|
|
212
|
+
# Add summary of execution failures (non-timeout errors) if any
|
|
213
|
+
non_timeout_failures = execution_failures - len(skipped_files)
|
|
214
|
+
if non_timeout_failures > 0:
|
|
215
|
+
failure_msg = (
|
|
216
|
+
f"Failed to process {non_timeout_failures} file(s) "
|
|
217
|
+
"due to execution errors"
|
|
218
|
+
)
|
|
219
|
+
if combined_output:
|
|
220
|
+
combined_output = f"{combined_output}\n\n{failure_msg}"
|
|
221
|
+
else:
|
|
222
|
+
combined_output = failure_msg
|
|
223
|
+
# issues_count reflects only linting issues, not execution failures
|
|
134
224
|
return ToolResult(
|
|
135
225
|
name=self.name,
|
|
136
226
|
success=all_success,
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
|
-
import shutil
|
|
6
5
|
import subprocess # nosec B404 - deliberate, shell disabled
|
|
7
6
|
import tomllib
|
|
8
7
|
from dataclasses import dataclass, field
|
|
@@ -243,15 +242,10 @@ class BanditTool(BaseTool):
|
|
|
243
242
|
Returns:
|
|
244
243
|
list[str]: List of command arguments.
|
|
245
244
|
"""
|
|
246
|
-
#
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
|
|
250
|
-
exec_cmd: list[str] = ["bandit"]
|
|
251
|
-
elif shutil.which("uvx"):
|
|
252
|
-
exec_cmd = ["uvx", "bandit"]
|
|
253
|
-
else:
|
|
254
|
-
exec_cmd = ["bandit"]
|
|
245
|
+
# Resolve executable via BaseTool preferences to ensure reliable
|
|
246
|
+
# execution inside the active environment (prefers 'uv run bandit' when
|
|
247
|
+
# available), falling back to a direct executable.
|
|
248
|
+
exec_cmd: list[str] = self._get_executable_command("bandit")
|
|
255
249
|
|
|
256
250
|
cmd: list[str] = exec_cmd + ["-r"]
|
|
257
251
|
|
|
@@ -325,10 +319,12 @@ class BanditTool(BaseTool):
|
|
|
325
319
|
|
|
326
320
|
Returns:
|
|
327
321
|
ToolResult: ToolResult instance.
|
|
328
|
-
|
|
329
|
-
Raises:
|
|
330
|
-
subprocess.TimeoutExpired: If the subprocess exceeds the timeout.
|
|
331
322
|
"""
|
|
323
|
+
# Check version requirements
|
|
324
|
+
version_result = self._verify_tool_version()
|
|
325
|
+
if version_result is not None:
|
|
326
|
+
return version_result
|
|
327
|
+
|
|
332
328
|
self._validate_paths(paths=paths)
|
|
333
329
|
if not paths:
|
|
334
330
|
return ToolResult(
|
|
@@ -367,26 +363,33 @@ class BanditTool(BaseTool):
|
|
|
367
363
|
cmd: list[str] = self._build_check_command(files=rel_files)
|
|
368
364
|
|
|
369
365
|
output: str
|
|
370
|
-
|
|
371
|
-
#
|
|
366
|
+
execution_failure: bool = False
|
|
367
|
+
# Run Bandit via the shared safe runner in BaseTool. This enforces
|
|
368
|
+
# argument validation and consistent subprocess handling across tools.
|
|
372
369
|
try:
|
|
373
|
-
|
|
374
|
-
cmd,
|
|
375
|
-
capture_output=True,
|
|
376
|
-
text=True,
|
|
370
|
+
success, combined = self._run_subprocess(
|
|
371
|
+
cmd=cmd,
|
|
377
372
|
timeout=timeout,
|
|
378
373
|
cwd=cwd,
|
|
379
|
-
)
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
stderr_text: str = result.stderr or ""
|
|
383
|
-
output = (stdout_text + "\n" + stderr_text).strip()
|
|
384
|
-
rc: int = result.returncode
|
|
374
|
+
)
|
|
375
|
+
output = (combined or "").strip()
|
|
376
|
+
rc: int = 0 if success else 1
|
|
385
377
|
except subprocess.TimeoutExpired:
|
|
386
|
-
|
|
378
|
+
# Handle timeout gracefully
|
|
379
|
+
execution_failure = True
|
|
380
|
+
timeout_msg = (
|
|
381
|
+
f"Bandit execution timed out ({timeout}s limit exceeded).\n\n"
|
|
382
|
+
"This may indicate:\n"
|
|
383
|
+
" - Large codebase taking too long to process\n"
|
|
384
|
+
" - Need to increase timeout via --tool-options bandit:timeout=N"
|
|
385
|
+
)
|
|
386
|
+
output = timeout_msg
|
|
387
|
+
rc = 1
|
|
387
388
|
except Exception as e:
|
|
388
389
|
logger.error(f"Failed to run Bandit: {e}")
|
|
389
|
-
output = ""
|
|
390
|
+
output = f"Bandit failed: {e}"
|
|
391
|
+
execution_failure = True
|
|
392
|
+
rc = 1
|
|
390
393
|
|
|
391
394
|
# Parse the JSON output
|
|
392
395
|
try:
|
|
@@ -409,12 +412,14 @@ class BanditTool(BaseTool):
|
|
|
409
412
|
issues_count = len(issues)
|
|
410
413
|
|
|
411
414
|
# Bandit returns 0 if no issues; 1 if issues found (still successful run)
|
|
412
|
-
execution_success =
|
|
415
|
+
execution_success = (
|
|
416
|
+
len(bandit_data.get("errors", [])) == 0 and not execution_failure
|
|
417
|
+
)
|
|
413
418
|
|
|
414
419
|
return ToolResult(
|
|
415
420
|
name=self.name,
|
|
416
421
|
success=execution_success,
|
|
417
|
-
output=None,
|
|
422
|
+
output=output if execution_failure else None,
|
|
418
423
|
issues_count=issues_count,
|
|
419
424
|
issues=issues,
|
|
420
425
|
)
|
|
@@ -10,12 +10,14 @@ Project: https://github.com/psf/black
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import os
|
|
13
|
+
import subprocess # nosec B404 - used safely with shell disabled
|
|
13
14
|
from dataclasses import dataclass, field
|
|
14
15
|
|
|
15
16
|
from loguru import logger
|
|
16
17
|
|
|
17
18
|
from lintro.enums.tool_type import ToolType
|
|
18
19
|
from lintro.models.core.tool import ToolConfig, ToolResult
|
|
20
|
+
from lintro.parsers.black.black_issue import BlackIssue
|
|
19
21
|
from lintro.parsers.black.black_parser import parse_black_output
|
|
20
22
|
from lintro.tools.core.tool_base import BaseTool
|
|
21
23
|
from lintro.utils.tool_utils import walk_files_with_excludes
|
|
@@ -94,17 +96,165 @@ class BlackTool(BaseTool):
|
|
|
94
96
|
super().set_options(**options, **kwargs)
|
|
95
97
|
|
|
96
98
|
def _build_common_args(self) -> list[str]:
|
|
99
|
+
"""Build common CLI arguments for Black.
|
|
100
|
+
|
|
101
|
+
Uses Lintro config injection when available, otherwise falls back
|
|
102
|
+
to options-based configuration.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
list[str]: CLI arguments for Black.
|
|
106
|
+
"""
|
|
97
107
|
args: list[str] = []
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
108
|
+
|
|
109
|
+
# Try Lintro config injection first (--config flag)
|
|
110
|
+
config_args = self._build_config_args()
|
|
111
|
+
if config_args:
|
|
112
|
+
args.extend(config_args)
|
|
113
|
+
else:
|
|
114
|
+
# Fallback to options-based configuration
|
|
115
|
+
if self.options.get("line_length"):
|
|
116
|
+
args.extend(["--line-length", str(self.options["line_length"])])
|
|
117
|
+
if self.options.get("target_version"):
|
|
118
|
+
args.extend(["--target-version", str(self.options["target_version"])])
|
|
119
|
+
|
|
120
|
+
# These flags are always passed via CLI (not in config file)
|
|
102
121
|
if self.options.get("fast"):
|
|
103
122
|
args.append("--fast")
|
|
104
123
|
if self.options.get("preview"):
|
|
105
124
|
args.append("--preview")
|
|
106
125
|
return args
|
|
107
126
|
|
|
127
|
+
def _check_line_length_violations(
|
|
128
|
+
self,
|
|
129
|
+
files: list[str],
|
|
130
|
+
cwd: str | None,
|
|
131
|
+
) -> list[BlackIssue]:
|
|
132
|
+
"""Check for line length violations using Ruff's E501 rule.
|
|
133
|
+
|
|
134
|
+
This catches lines that exceed the line length limit but cannot be
|
|
135
|
+
safely wrapped by Black. Black's --check mode only reports files that
|
|
136
|
+
would be reformatted, so it misses unwrappable long lines.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
files: List of file paths (relative to cwd) to check.
|
|
140
|
+
cwd: Working directory for the check.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
list[BlackIssue]: List of line length violations converted to
|
|
144
|
+
BlackIssue objects.
|
|
145
|
+
"""
|
|
146
|
+
if not files:
|
|
147
|
+
return []
|
|
148
|
+
|
|
149
|
+
# Import here to avoid circular dependency
|
|
150
|
+
from lintro.tools.implementations.tool_ruff import RuffTool
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
ruff_tool = RuffTool()
|
|
154
|
+
# Use the same line_length as Black is configured with
|
|
155
|
+
line_length = self.options.get("line_length")
|
|
156
|
+
if line_length:
|
|
157
|
+
ruff_tool.set_options(
|
|
158
|
+
select=["E501"],
|
|
159
|
+
line_length=line_length,
|
|
160
|
+
timeout=self.options.get("timeout", BLACK_DEFAULT_TIMEOUT),
|
|
161
|
+
)
|
|
162
|
+
else:
|
|
163
|
+
# If no line_length configured, use Ruff's default (88)
|
|
164
|
+
ruff_tool.set_options(
|
|
165
|
+
select=["E501"],
|
|
166
|
+
timeout=self.options.get("timeout", BLACK_DEFAULT_TIMEOUT),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Convert relative paths to absolute paths for RuffTool
|
|
170
|
+
abs_files: list[str] = []
|
|
171
|
+
for file_path in files:
|
|
172
|
+
if cwd and not os.path.isabs(file_path):
|
|
173
|
+
abs_files.append(os.path.abspath(os.path.join(cwd, file_path)))
|
|
174
|
+
else:
|
|
175
|
+
abs_files.append(file_path)
|
|
176
|
+
|
|
177
|
+
# Run Ruff E501 check on the absolute file paths
|
|
178
|
+
ruff_result = ruff_tool.check(paths=abs_files)
|
|
179
|
+
|
|
180
|
+
# Convert Ruff E501 violations to BlackIssue objects
|
|
181
|
+
black_issues: list[BlackIssue] = []
|
|
182
|
+
if ruff_result.issues:
|
|
183
|
+
for ruff_issue in ruff_result.issues:
|
|
184
|
+
# Only process E501 violations
|
|
185
|
+
if hasattr(ruff_issue, "code") and ruff_issue.code == "E501":
|
|
186
|
+
file_path = ruff_issue.file
|
|
187
|
+
# Ensure absolute path
|
|
188
|
+
if not os.path.isabs(file_path):
|
|
189
|
+
if cwd:
|
|
190
|
+
file_path = os.path.abspath(
|
|
191
|
+
os.path.join(cwd, file_path),
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
file_path = os.path.abspath(file_path)
|
|
195
|
+
message = (
|
|
196
|
+
f"Line {ruff_issue.line} exceeds line length limit "
|
|
197
|
+
f"({ruff_issue.message})"
|
|
198
|
+
)
|
|
199
|
+
# Line length violations cannot be fixed by Black
|
|
200
|
+
black_issues.append(
|
|
201
|
+
BlackIssue(
|
|
202
|
+
file=file_path,
|
|
203
|
+
message=message,
|
|
204
|
+
fixable=False,
|
|
205
|
+
),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
except Exception as e:
|
|
209
|
+
# If Ruff check fails, log but don't fail the entire Black check
|
|
210
|
+
logger.debug(f"Failed to check line length violations with Ruff: {e}")
|
|
211
|
+
return []
|
|
212
|
+
|
|
213
|
+
return black_issues
|
|
214
|
+
|
|
215
|
+
def _handle_timeout_error(
|
|
216
|
+
self,
|
|
217
|
+
timeout_val: int,
|
|
218
|
+
initial_count: int | None = None,
|
|
219
|
+
) -> ToolResult:
|
|
220
|
+
"""Handle timeout errors consistently across all Black operations.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
timeout_val: The timeout value that was exceeded.
|
|
224
|
+
initial_count: Optional initial issues count for fix operations.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
ToolResult: Standardized timeout error result.
|
|
228
|
+
"""
|
|
229
|
+
timeout_msg = (
|
|
230
|
+
f"Black execution timed out ({timeout_val}s limit exceeded).\n\n"
|
|
231
|
+
"This may indicate:\n"
|
|
232
|
+
" - Large codebase taking too long to process\n"
|
|
233
|
+
" - Need to increase timeout via --tool-options black:timeout=N"
|
|
234
|
+
)
|
|
235
|
+
if initial_count is not None:
|
|
236
|
+
# Fix operation timeout - preserve known initial count
|
|
237
|
+
return ToolResult(
|
|
238
|
+
name=self.name,
|
|
239
|
+
success=False,
|
|
240
|
+
output=timeout_msg,
|
|
241
|
+
issues_count=max(initial_count, 1),
|
|
242
|
+
issues=[],
|
|
243
|
+
initial_issues_count=(
|
|
244
|
+
initial_count if not self.options.get("diff") else 0
|
|
245
|
+
),
|
|
246
|
+
fixed_issues_count=0,
|
|
247
|
+
remaining_issues_count=max(initial_count, 1),
|
|
248
|
+
)
|
|
249
|
+
# Check operation timeout
|
|
250
|
+
return ToolResult(
|
|
251
|
+
name=self.name,
|
|
252
|
+
success=False,
|
|
253
|
+
output=timeout_msg,
|
|
254
|
+
issues_count=1, # Count timeout as execution failure
|
|
255
|
+
issues=[],
|
|
256
|
+
)
|
|
257
|
+
|
|
108
258
|
def check(self, paths: list[str]) -> ToolResult:
|
|
109
259
|
"""Check files using Black without applying changes.
|
|
110
260
|
|
|
@@ -114,6 +264,11 @@ class BlackTool(BaseTool):
|
|
|
114
264
|
Returns:
|
|
115
265
|
ToolResult: Result containing success flag, issue count, and issues.
|
|
116
266
|
"""
|
|
267
|
+
# Check version requirements
|
|
268
|
+
version_result = self._verify_tool_version()
|
|
269
|
+
if version_result is not None:
|
|
270
|
+
return version_result
|
|
271
|
+
|
|
117
272
|
self._validate_paths(paths=paths)
|
|
118
273
|
|
|
119
274
|
py_files: list[str] = walk_files_with_excludes(
|
|
@@ -141,21 +296,36 @@ class BlackTool(BaseTool):
|
|
|
141
296
|
cmd.extend(rel_files)
|
|
142
297
|
|
|
143
298
|
logger.debug(f"[BlackTool] Running: {' '.join(cmd)} (cwd={cwd})")
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
299
|
+
timeout_val: int = self.options.get("timeout", BLACK_DEFAULT_TIMEOUT)
|
|
300
|
+
try:
|
|
301
|
+
success, output = self._run_subprocess(
|
|
302
|
+
cmd=cmd,
|
|
303
|
+
timeout=timeout_val,
|
|
304
|
+
cwd=cwd,
|
|
305
|
+
)
|
|
306
|
+
except subprocess.TimeoutExpired:
|
|
307
|
+
return self._handle_timeout_error(timeout_val)
|
|
308
|
+
|
|
309
|
+
black_issues = parse_black_output(output=output)
|
|
310
|
+
|
|
311
|
+
# Also check for line length violations that Black cannot wrap
|
|
312
|
+
# This catches E501 violations that Ruff finds but Black doesn't report
|
|
313
|
+
line_length_issues = self._check_line_length_violations(
|
|
314
|
+
files=rel_files,
|
|
147
315
|
cwd=cwd,
|
|
148
316
|
)
|
|
149
317
|
|
|
150
|
-
issues
|
|
151
|
-
|
|
318
|
+
# Combine Black formatting issues with line length violations
|
|
319
|
+
all_issues = black_issues + line_length_issues
|
|
320
|
+
count = len(all_issues)
|
|
321
|
+
|
|
152
322
|
# In check mode, success means no differences
|
|
153
323
|
return ToolResult(
|
|
154
324
|
name=self.name,
|
|
155
325
|
success=(success and count == 0),
|
|
156
326
|
output=None if count == 0 else output,
|
|
157
327
|
issues_count=count,
|
|
158
|
-
issues=
|
|
328
|
+
issues=all_issues,
|
|
159
329
|
)
|
|
160
330
|
|
|
161
331
|
def fix(self, paths: list[str]) -> ToolResult:
|
|
@@ -167,6 +337,11 @@ class BlackTool(BaseTool):
|
|
|
167
337
|
Returns:
|
|
168
338
|
ToolResult: Result containing counts and any remaining issues.
|
|
169
339
|
"""
|
|
340
|
+
# Check version requirements
|
|
341
|
+
version_result = self._verify_tool_version()
|
|
342
|
+
if version_result is not None:
|
|
343
|
+
return version_result
|
|
344
|
+
|
|
170
345
|
self._validate_paths(paths=paths)
|
|
171
346
|
|
|
172
347
|
py_files: list[str] = walk_files_with_excludes(
|
|
@@ -196,15 +371,19 @@ class BlackTool(BaseTool):
|
|
|
196
371
|
# When diff is requested, skip the initial check to ensure the middle
|
|
197
372
|
# invocation is the formatting run (as exercised by unit tests) and to
|
|
198
373
|
# avoid redundant subprocess calls.
|
|
374
|
+
timeout_val: int = self.options.get("timeout", BLACK_DEFAULT_TIMEOUT)
|
|
199
375
|
if self.options.get("diff"):
|
|
200
376
|
initial_issues = []
|
|
201
377
|
initial_count = 0
|
|
202
378
|
else:
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
379
|
+
try:
|
|
380
|
+
_, check_output = self._run_subprocess(
|
|
381
|
+
cmd=check_cmd,
|
|
382
|
+
timeout=timeout_val,
|
|
383
|
+
cwd=cwd,
|
|
384
|
+
)
|
|
385
|
+
except subprocess.TimeoutExpired:
|
|
386
|
+
return self._handle_timeout_error(timeout_val, initial_count=0)
|
|
208
387
|
initial_issues = parse_black_output(output=check_output)
|
|
209
388
|
initial_count = len(initial_issues)
|
|
210
389
|
|
|
@@ -219,22 +398,50 @@ class BlackTool(BaseTool):
|
|
|
219
398
|
fix_cmd.extend(rel_files)
|
|
220
399
|
|
|
221
400
|
logger.debug(f"[BlackTool] Fixing: {' '.join(fix_cmd)} (cwd={cwd})")
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
401
|
+
try:
|
|
402
|
+
_, fix_output = self._run_subprocess(
|
|
403
|
+
cmd=fix_cmd,
|
|
404
|
+
timeout=timeout_val,
|
|
405
|
+
cwd=cwd,
|
|
406
|
+
)
|
|
407
|
+
except subprocess.TimeoutExpired:
|
|
408
|
+
return self._handle_timeout_error(timeout_val, initial_count=initial_count)
|
|
227
409
|
|
|
228
410
|
# Final check for remaining differences
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
411
|
+
try:
|
|
412
|
+
final_success, final_output = self._run_subprocess(
|
|
413
|
+
cmd=check_cmd,
|
|
414
|
+
timeout=timeout_val,
|
|
415
|
+
cwd=cwd,
|
|
416
|
+
)
|
|
417
|
+
except subprocess.TimeoutExpired:
|
|
418
|
+
return self._handle_timeout_error(timeout_val, initial_count=initial_count)
|
|
419
|
+
remaining_issues = parse_black_output(output=final_output)
|
|
420
|
+
|
|
421
|
+
# Also check for line length violations that Black cannot wrap
|
|
422
|
+
# This catches E501 violations that Ruff finds but Black doesn't report
|
|
423
|
+
line_length_issues = self._check_line_length_violations(
|
|
424
|
+
files=rel_files,
|
|
232
425
|
cwd=cwd,
|
|
233
426
|
)
|
|
234
|
-
remaining_issues = parse_black_output(output=final_output)
|
|
235
|
-
remaining_count = len(remaining_issues)
|
|
236
427
|
|
|
237
|
-
|
|
428
|
+
# Combine Black formatting issues with line length violations
|
|
429
|
+
all_remaining_issues = remaining_issues + line_length_issues
|
|
430
|
+
remaining_count = len(all_remaining_issues)
|
|
431
|
+
|
|
432
|
+
# Parse per-file reformats from the formatting run to display in console
|
|
433
|
+
fixed_issues_parsed = parse_black_output(output=fix_output)
|
|
434
|
+
fixed_count_from_output = len(fixed_issues_parsed)
|
|
435
|
+
|
|
436
|
+
# Calculate fixed count: use reformatted files count if available,
|
|
437
|
+
# otherwise calculate from initial - remaining
|
|
438
|
+
if fixed_count_from_output > 0:
|
|
439
|
+
fixed_count = fixed_count_from_output
|
|
440
|
+
else:
|
|
441
|
+
# Subtract only Black-related remaining issues (exclude line-length)
|
|
442
|
+
line_length_issues_count = len(line_length_issues)
|
|
443
|
+
remaining_black_count = max(0, remaining_count - line_length_issues_count)
|
|
444
|
+
fixed_count = max(0, initial_count - remaining_black_count)
|
|
238
445
|
|
|
239
446
|
# Build concise summary
|
|
240
447
|
summary: list[str] = []
|
|
@@ -246,15 +453,15 @@ class BlackTool(BaseTool):
|
|
|
246
453
|
)
|
|
247
454
|
final_summary = "\n".join(summary) if summary else "No fixes applied."
|
|
248
455
|
|
|
249
|
-
#
|
|
250
|
-
|
|
456
|
+
# Combine fixed and remaining issues so formatter can split them by fixability
|
|
457
|
+
all_issues = (fixed_issues_parsed or []) + all_remaining_issues
|
|
251
458
|
|
|
252
459
|
return ToolResult(
|
|
253
460
|
name=self.name,
|
|
254
461
|
success=(remaining_count == 0),
|
|
255
462
|
output=final_summary,
|
|
256
463
|
issues_count=remaining_count,
|
|
257
|
-
issues=
|
|
464
|
+
issues=all_issues,
|
|
258
465
|
initial_issues_count=initial_count,
|
|
259
466
|
fixed_issues_count=fixed_count,
|
|
260
467
|
remaining_issues_count=remaining_count,
|