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.
- lintro/__init__.py +1 -1
- lintro/formatters/tools/actionlint_formatter.py +1 -1
- lintro/formatters/tools/bandit_formatter.py +1 -1
- lintro/formatters/tools/black_formatter.py +48 -0
- lintro/formatters/tools/darglint_formatter.py +3 -1
- lintro/parsers/black/black_issue.py +22 -0
- lintro/parsers/black/black_parser.py +90 -0
- lintro/parsers/darglint/darglint_parser.py +1 -1
- lintro/parsers/hadolint/hadolint_parser.py +2 -2
- lintro/parsers/ruff/ruff_parser.py +87 -36
- lintro/parsers/yamllint/yamllint_parser.py +2 -2
- lintro/tools/__init__.py +2 -2
- lintro/tools/core/tool_base.py +41 -11
- lintro/tools/implementations/tool_bandit.py +4 -2
- lintro/tools/implementations/tool_black.py +261 -0
- lintro/tools/implementations/tool_prettier.py +1 -1
- lintro/tools/implementations/tool_ruff.py +47 -25
- lintro/tools/tool_enum.py +2 -0
- lintro/utils/ascii_normalize_cli.py +3 -1
- lintro/utils/config.py +16 -0
- lintro/utils/console_logger.py +59 -11
- lintro/utils/output_manager.py +2 -2
- lintro/utils/tool_executor.py +214 -7
- lintro/utils/tool_utils.py +12 -1
- {lintro-0.5.3.dist-info → lintro-0.6.1.dist-info}/METADATA +9 -7
- {lintro-0.5.3.dist-info → lintro-0.6.1.dist-info}/RECORD +30 -26
- {lintro-0.5.3.dist-info → lintro-0.6.1.dist-info}/WHEEL +0 -0
- {lintro-0.5.3.dist-info → lintro-0.6.1.dist-info}/entry_points.txt +0 -0
- {lintro-0.5.3.dist-info → lintro-0.6.1.dist-info}/licenses/LICENSE +0 -0
- {lintro-0.5.3.dist-info → lintro-0.6.1.dist-info}/top_level.txt +0 -0
lintro/__init__.py
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Formatter for Black issues."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from lintro.formatters.core.table_descriptor import TableDescriptor
|
|
6
|
+
from lintro.formatters.styles.csv import CsvStyle
|
|
7
|
+
from lintro.formatters.styles.grid import GridStyle
|
|
8
|
+
from lintro.formatters.styles.html import HtmlStyle
|
|
9
|
+
from lintro.formatters.styles.json import JsonStyle
|
|
10
|
+
from lintro.formatters.styles.markdown import MarkdownStyle
|
|
11
|
+
from lintro.formatters.styles.plain import PlainStyle
|
|
12
|
+
from lintro.parsers.black.black_issue import BlackIssue
|
|
13
|
+
from lintro.utils.path_utils import normalize_file_path_for_display
|
|
14
|
+
|
|
15
|
+
FORMAT_MAP = {
|
|
16
|
+
"plain": PlainStyle(),
|
|
17
|
+
"grid": GridStyle(),
|
|
18
|
+
"markdown": MarkdownStyle(),
|
|
19
|
+
"html": HtmlStyle(),
|
|
20
|
+
"json": JsonStyle(),
|
|
21
|
+
"csv": CsvStyle(),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BlackTableDescriptor(TableDescriptor):
|
|
26
|
+
def get_columns(self) -> list[str]:
|
|
27
|
+
return ["File", "Message"]
|
|
28
|
+
|
|
29
|
+
def get_rows(self, issues: list[BlackIssue]) -> list[list[str]]:
|
|
30
|
+
rows: list[list[str]] = []
|
|
31
|
+
for issue in issues:
|
|
32
|
+
rows.append(
|
|
33
|
+
[
|
|
34
|
+
normalize_file_path_for_display(issue.file),
|
|
35
|
+
issue.message,
|
|
36
|
+
],
|
|
37
|
+
)
|
|
38
|
+
return rows
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def format_black_issues(issues: list[BlackIssue], format: str = "grid") -> str:
|
|
42
|
+
descriptor = BlackTableDescriptor()
|
|
43
|
+
formatter = FORMAT_MAP.get(format, GridStyle())
|
|
44
|
+
columns = descriptor.get_columns()
|
|
45
|
+
rows = descriptor.get_rows(issues)
|
|
46
|
+
if format == "json":
|
|
47
|
+
return formatter.format(columns=columns, rows=rows, tool_name="black")
|
|
48
|
+
return formatter.format(columns=columns, rows=rows)
|
|
@@ -63,7 +63,9 @@ def format_darglint_issues(
|
|
|
63
63
|
# For JSON format, pass tool name
|
|
64
64
|
if format == "json":
|
|
65
65
|
formatted_table = formatter.format(
|
|
66
|
-
columns=columns,
|
|
66
|
+
columns=columns,
|
|
67
|
+
rows=rows,
|
|
68
|
+
tool_name="darglint",
|
|
67
69
|
)
|
|
68
70
|
else:
|
|
69
71
|
# For other formats, use standard formatting
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Black issue models.
|
|
2
|
+
|
|
3
|
+
This module defines lightweight dataclasses used to represent Black findings
|
|
4
|
+
in a normalized form that Lintro formatters can consume.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class BlackIssue:
|
|
14
|
+
"""Represents a Black formatting issue.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
file: Path to the file with a formatting difference.
|
|
18
|
+
message: Short human-readable description (e.g., "Would reformat file").
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
file: str
|
|
22
|
+
message: str
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Parser for Black output.
|
|
2
|
+
|
|
3
|
+
Black commonly emits terse messages like:
|
|
4
|
+
- "would reformat foo.py" (check mode with --check)
|
|
5
|
+
- "reformatted foo.py" (fix mode)
|
|
6
|
+
- a summary line like "1 file would be reformatted" or
|
|
7
|
+
"2 files reformatted" (with no per-file lines in some environments).
|
|
8
|
+
|
|
9
|
+
We normalize items into ``BlackIssue`` objects so the table formatter can
|
|
10
|
+
render consistent rows. When only a summary is present, we synthesize one
|
|
11
|
+
``BlackIssue`` per counted file with ``file`` set to "<unknown>" so totals
|
|
12
|
+
remain accurate across environments.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
|
|
20
|
+
from lintro.parsers.black.black_issue import BlackIssue
|
|
21
|
+
|
|
22
|
+
_WOULD_REFORMAT = re.compile(r"^would reformat\s+(?P<file>.+)$", re.IGNORECASE)
|
|
23
|
+
_REFORMATTED = re.compile(r"^reformatted\s+(?P<file>.+)$", re.IGNORECASE)
|
|
24
|
+
_SUMMARY_WOULD = re.compile(
|
|
25
|
+
r"(?P<count>\d+)\s+file(?:s)?\s+would\s+be\s+reformatted\.?",
|
|
26
|
+
re.IGNORECASE,
|
|
27
|
+
)
|
|
28
|
+
_SUMMARY_REFORMATTED = re.compile(
|
|
29
|
+
r"(?P<count>\d+)\s+file(?:s)?\s+reformatted\.?",
|
|
30
|
+
re.IGNORECASE,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _iter_issue_lines(lines: Iterable[str]) -> Iterable[str]:
|
|
35
|
+
for line in lines:
|
|
36
|
+
s = line.strip()
|
|
37
|
+
if not s:
|
|
38
|
+
continue
|
|
39
|
+
yield s
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_black_output(output: str) -> list[BlackIssue]:
|
|
43
|
+
"""Parse Black CLI output into a list of ``BlackIssue`` objects.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
output: Raw stdout+stderr from a Black invocation.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
list[BlackIssue]: Per-file issues indicating formatting diffs. If only
|
|
50
|
+
a summary is present (no per-file lines), returns a synthesized list
|
|
51
|
+
sized to the summary count with ``file`` set to "<unknown>".
|
|
52
|
+
"""
|
|
53
|
+
if not output:
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
issues: list[BlackIssue] = []
|
|
57
|
+
for line in _iter_issue_lines(output.splitlines()):
|
|
58
|
+
m = _WOULD_REFORMAT.match(line)
|
|
59
|
+
if m:
|
|
60
|
+
issues.append(
|
|
61
|
+
BlackIssue(file=m.group("file"), message="Would reformat file"),
|
|
62
|
+
)
|
|
63
|
+
continue
|
|
64
|
+
m = _REFORMATTED.match(line)
|
|
65
|
+
if m:
|
|
66
|
+
issues.append(BlackIssue(file=m.group("file"), message="Reformatted file"))
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
# Some environments (e.g., CI) may emit only a summary line without listing
|
|
70
|
+
# per-file entries. In that case, synthesize issues so counts remain
|
|
71
|
+
# consistent across environments.
|
|
72
|
+
if not issues:
|
|
73
|
+
m_sum = _SUMMARY_WOULD.search(output)
|
|
74
|
+
if not m_sum:
|
|
75
|
+
m_sum = _SUMMARY_REFORMATTED.search(output)
|
|
76
|
+
if m_sum:
|
|
77
|
+
try:
|
|
78
|
+
count = int(m_sum.group("count"))
|
|
79
|
+
except Exception:
|
|
80
|
+
count = 0
|
|
81
|
+
if count > 0:
|
|
82
|
+
for _ in range(count):
|
|
83
|
+
issues.append(
|
|
84
|
+
BlackIssue(
|
|
85
|
+
file="<unknown>",
|
|
86
|
+
message="Formatting change detected",
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return issues
|
|
@@ -32,7 +32,7 @@ def parse_hadolint_output(output: str) -> list[HadolintIssue]:
|
|
|
32
32
|
|
|
33
33
|
# Pattern for hadolint output: filename:line code level: message
|
|
34
34
|
pattern: re.Pattern[str] = re.compile(
|
|
35
|
-
r"^(.+?):(\d+)\s+([A-Z]+\d+)\s+(error|warning|info|style):\s+(.+)$"
|
|
35
|
+
r"^(.+?):(\d+)\s+([A-Z]+\d+)\s+(error|warning|info|style):\s+(.+)$",
|
|
36
36
|
)
|
|
37
37
|
|
|
38
38
|
lines: list[str] = output.splitlines()
|
|
@@ -59,7 +59,7 @@ def parse_hadolint_output(output: str) -> list[HadolintIssue]:
|
|
|
59
59
|
level=level,
|
|
60
60
|
code=code,
|
|
61
61
|
message=message.strip(),
|
|
62
|
-
)
|
|
62
|
+
),
|
|
63
63
|
)
|
|
64
64
|
|
|
65
65
|
return issues
|
|
@@ -11,58 +11,109 @@ from lintro.parsers.ruff.ruff_issue import RuffIssue
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def parse_ruff_output(output: str) -> list[RuffIssue]:
|
|
14
|
-
"""Parse
|
|
14
|
+
"""Parse Ruff JSON or JSON Lines output into `RuffIssue` objects.
|
|
15
|
+
|
|
16
|
+
Supports multiple Ruff schema variants across versions by accepting:
|
|
17
|
+
- JSON array of issue objects
|
|
18
|
+
- JSON Lines (one object per line)
|
|
19
|
+
|
|
20
|
+
Field name variations handled:
|
|
21
|
+
- location: "location" or "start" with keys "row"|"line" and
|
|
22
|
+
"column"|"col"
|
|
23
|
+
- end location: "end_location" or "end" with keys "row"|"line" and
|
|
24
|
+
"column"|"col"
|
|
25
|
+
- filename: "filename" (preferred) or "file"
|
|
15
26
|
|
|
16
27
|
Args:
|
|
17
|
-
output:
|
|
28
|
+
output: Raw output from `ruff check --output-format json`.
|
|
18
29
|
|
|
19
30
|
Returns:
|
|
20
|
-
|
|
31
|
+
list[RuffIssue]: Parsed issues.
|
|
21
32
|
"""
|
|
22
33
|
issues: list[RuffIssue] = []
|
|
23
34
|
|
|
24
|
-
if not output or output.strip()
|
|
35
|
+
if not output or output.strip() in ("[]", "{}"):
|
|
25
36
|
return issues
|
|
26
37
|
|
|
38
|
+
def _int_from(d: dict, candidates: list[str]) -> int | None:
|
|
39
|
+
for key in candidates:
|
|
40
|
+
val = d.get(key)
|
|
41
|
+
if isinstance(val, int):
|
|
42
|
+
return val
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
def _parse_item(item: dict) -> RuffIssue | None:
|
|
46
|
+
try:
|
|
47
|
+
filename: str = item.get("filename") or item.get("file") or ""
|
|
48
|
+
|
|
49
|
+
loc: dict = item.get("location") or item.get("start") or {}
|
|
50
|
+
end_loc: dict = item.get("end_location") or item.get("end") or {}
|
|
51
|
+
|
|
52
|
+
line = _int_from(loc, ["row", "line"]) or 0
|
|
53
|
+
column = _int_from(loc, ["column", "col"]) or 0
|
|
54
|
+
end_line = _int_from(end_loc, ["row", "line"]) or line
|
|
55
|
+
end_column = _int_from(end_loc, ["column", "col"]) or column
|
|
56
|
+
|
|
57
|
+
code: str = item.get("code") or item.get("rule") or ""
|
|
58
|
+
message: str = item.get("message") or ""
|
|
59
|
+
url: str | None = item.get("url")
|
|
60
|
+
|
|
61
|
+
fix = item.get("fix") or {}
|
|
62
|
+
fixable: bool = bool(fix)
|
|
63
|
+
fix_applicability = (
|
|
64
|
+
fix.get("applicability") if isinstance(fix, dict) else None
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return RuffIssue(
|
|
68
|
+
file=filename,
|
|
69
|
+
line=line,
|
|
70
|
+
column=column,
|
|
71
|
+
code=code,
|
|
72
|
+
message=message,
|
|
73
|
+
url=url,
|
|
74
|
+
end_line=end_line,
|
|
75
|
+
end_column=end_column,
|
|
76
|
+
fixable=fixable,
|
|
77
|
+
fix_applicability=fix_applicability,
|
|
78
|
+
)
|
|
79
|
+
except Exception:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
# First try JSON array (with possible trailing non-JSON data)
|
|
27
83
|
try:
|
|
28
|
-
# Ruff outputs JSON array of issue objects, but may have warnings
|
|
29
|
-
# after. Find the end of the JSON array by looking for the
|
|
30
|
-
# closing
|
|
31
|
-
# bracket
|
|
32
84
|
json_end = output.rfind("]")
|
|
33
|
-
if json_end
|
|
34
|
-
# No closing bracket found, try to parse the whole output
|
|
35
|
-
ruff_data = json.loads(output)
|
|
36
|
-
else:
|
|
37
|
-
# Extract just the JSON part (up to and including the closing bracket)
|
|
85
|
+
if json_end != -1:
|
|
38
86
|
json_part = output[: json_end + 1]
|
|
39
87
|
ruff_data = json.loads(json_part)
|
|
88
|
+
else:
|
|
89
|
+
ruff_data = json.loads(output)
|
|
40
90
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
column=item["location"]["column"],
|
|
52
|
-
code=item["code"],
|
|
53
|
-
message=item["message"],
|
|
54
|
-
url=item.get("url"),
|
|
55
|
-
end_line=item["end_location"]["row"],
|
|
56
|
-
end_column=item["end_location"]["column"],
|
|
57
|
-
fixable=bool(item.get("fix")),
|
|
58
|
-
fix_applicability=fix_applicability,
|
|
59
|
-
),
|
|
60
|
-
)
|
|
61
|
-
except (json.JSONDecodeError, KeyError, TypeError):
|
|
62
|
-
# If JSON parsing fails, return empty list
|
|
63
|
-
# Could also log the error for debugging
|
|
91
|
+
if isinstance(ruff_data, list):
|
|
92
|
+
for item in ruff_data:
|
|
93
|
+
if not isinstance(item, dict):
|
|
94
|
+
continue
|
|
95
|
+
parsed = _parse_item(item)
|
|
96
|
+
if parsed is not None:
|
|
97
|
+
issues.append(parsed)
|
|
98
|
+
return issues
|
|
99
|
+
except (json.JSONDecodeError, TypeError):
|
|
100
|
+
# Fall back to JSON Lines parsing below
|
|
64
101
|
pass
|
|
65
102
|
|
|
103
|
+
# Fallback: parse JSON Lines (each line is a JSON object)
|
|
104
|
+
for line in output.splitlines():
|
|
105
|
+
line_str = line.strip()
|
|
106
|
+
if not line_str or not line_str.startswith("{"):
|
|
107
|
+
continue
|
|
108
|
+
try:
|
|
109
|
+
item = json.loads(line_str)
|
|
110
|
+
if isinstance(item, dict):
|
|
111
|
+
parsed = _parse_item(item)
|
|
112
|
+
if parsed is not None:
|
|
113
|
+
issues.append(parsed)
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
continue
|
|
116
|
+
|
|
66
117
|
return issues
|
|
67
118
|
|
|
68
119
|
|
|
@@ -41,7 +41,7 @@ def parse_yamllint_output(output: str) -> list[YamllintIssue]:
|
|
|
41
41
|
# Pattern for yamllint parsable format: "filename:line:column: [level]
|
|
42
42
|
# message (rule)"
|
|
43
43
|
pattern: re.Pattern[str] = re.compile(
|
|
44
|
-
r"^([^:]+):(\d+):(\d+):\s*\[(error|warning)\]\s+(.+?)(?:\s+\(([^)]+)\))?$"
|
|
44
|
+
r"^([^:]+):(\d+):(\d+):\s*\[(error|warning)\]\s+(.+?)(?:\s+\(([^)]+)\))?$",
|
|
45
45
|
)
|
|
46
46
|
|
|
47
47
|
match: re.Match[str] | None = pattern.match(line)
|
|
@@ -62,7 +62,7 @@ def parse_yamllint_output(output: str) -> list[YamllintIssue]:
|
|
|
62
62
|
level=level,
|
|
63
63
|
rule=rule,
|
|
64
64
|
message=message.strip(),
|
|
65
|
-
)
|
|
65
|
+
),
|
|
66
66
|
)
|
|
67
67
|
|
|
68
68
|
return issues
|
lintro/tools/__init__.py
CHANGED
|
@@ -4,10 +4,9 @@ from lintro.enums.tool_type import ToolType
|
|
|
4
4
|
from lintro.models.core.tool import Tool
|
|
5
5
|
from lintro.models.core.tool_config import ToolConfig
|
|
6
6
|
from lintro.tools.core.tool_manager import ToolManager
|
|
7
|
-
|
|
8
|
-
# Import core implementations after Tool class definition to avoid circular imports
|
|
9
7
|
from lintro.tools.implementations.tool_actionlint import ActionlintTool
|
|
10
8
|
from lintro.tools.implementations.tool_bandit import BanditTool
|
|
9
|
+
from lintro.tools.implementations.tool_black import BlackTool
|
|
11
10
|
from lintro.tools.implementations.tool_darglint import DarglintTool
|
|
12
11
|
from lintro.tools.implementations.tool_hadolint import HadolintTool
|
|
13
12
|
from lintro.tools.implementations.tool_prettier import PrettierTool
|
|
@@ -34,6 +33,7 @@ __all__ = [
|
|
|
34
33
|
"ToolEnum",
|
|
35
34
|
"tool_manager",
|
|
36
35
|
"AVAILABLE_TOOLS",
|
|
36
|
+
"BlackTool",
|
|
37
37
|
"ActionlintTool",
|
|
38
38
|
"BanditTool",
|
|
39
39
|
"DarglintTool",
|
lintro/tools/core/tool_base.py
CHANGED
|
@@ -61,7 +61,7 @@ class BaseTool(ABC):
|
|
|
61
61
|
|
|
62
62
|
_default_timeout: int = DEFAULT_TIMEOUT
|
|
63
63
|
_default_exclude_patterns: list[str] = field(
|
|
64
|
-
default_factory=lambda: DEFAULT_EXCLUDE_PATTERNS
|
|
64
|
+
default_factory=lambda: DEFAULT_EXCLUDE_PATTERNS,
|
|
65
65
|
)
|
|
66
66
|
|
|
67
67
|
def __post_init__(self) -> None:
|
|
@@ -256,9 +256,10 @@ class BaseTool(ABC):
|
|
|
256
256
|
) -> list[str]:
|
|
257
257
|
"""Get the command prefix to execute a tool.
|
|
258
258
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
259
|
+
Prefer running via ``uv run`` when available to ensure the tool executes
|
|
260
|
+
within the active Python environment, avoiding PATH collisions with
|
|
261
|
+
user-level shims. Fall back to a direct executable when ``uv`` is not
|
|
262
|
+
present, and finally to the bare tool name.
|
|
262
263
|
|
|
263
264
|
Args:
|
|
264
265
|
tool_name: str: Name of the tool executable to find.
|
|
@@ -268,20 +269,49 @@ class BaseTool(ABC):
|
|
|
268
269
|
|
|
269
270
|
Examples:
|
|
270
271
|
>>> self._get_executable_command("ruff")
|
|
271
|
-
["ruff"] #
|
|
272
|
+
["uv", "run", "ruff"] # preferred when uv is available
|
|
272
273
|
|
|
273
274
|
>>> self._get_executable_command("ruff")
|
|
274
|
-
["
|
|
275
|
+
["ruff"] # if uv is not available but the tool is on PATH
|
|
275
276
|
"""
|
|
276
|
-
#
|
|
277
|
-
|
|
277
|
+
# Tool-specific preferences to balance reliability vs. historical expectations
|
|
278
|
+
python_tools_prefer_uv = {"black", "bandit", "yamllint", "darglint"}
|
|
279
|
+
|
|
280
|
+
# Ruff: keep historical expectation for tests (direct invocation first)
|
|
281
|
+
if tool_name == "ruff":
|
|
282
|
+
if shutil.which(tool_name):
|
|
283
|
+
return [tool_name]
|
|
284
|
+
if shutil.which("uv"):
|
|
285
|
+
return ["uv", "run", tool_name]
|
|
286
|
+
return [tool_name]
|
|
287
|
+
|
|
288
|
+
# Black: prefer system binary first, then project env via uv run,
|
|
289
|
+
# and finally uvx as a last resort.
|
|
290
|
+
if tool_name == "black":
|
|
291
|
+
if shutil.which(tool_name):
|
|
292
|
+
return [tool_name]
|
|
293
|
+
if shutil.which("uv"):
|
|
294
|
+
return ["uv", "run", tool_name]
|
|
295
|
+
if shutil.which("uvx"):
|
|
296
|
+
return ["uvx", tool_name]
|
|
297
|
+
return [tool_name]
|
|
298
|
+
|
|
299
|
+
# Python-based tools where running inside env avoids PATH shim issues
|
|
300
|
+
if tool_name in python_tools_prefer_uv:
|
|
301
|
+
if shutil.which(tool_name):
|
|
302
|
+
return [tool_name]
|
|
303
|
+
if shutil.which("uv"):
|
|
304
|
+
return ["uv", "run", tool_name]
|
|
305
|
+
if shutil.which("uvx"):
|
|
306
|
+
return ["uvx", tool_name]
|
|
278
307
|
return [tool_name]
|
|
279
308
|
|
|
280
|
-
#
|
|
309
|
+
# Default: prefer direct system executable (node/binary tools like
|
|
310
|
+
# prettier, hadolint, actionlint)
|
|
311
|
+
if shutil.which(tool_name):
|
|
312
|
+
return [tool_name]
|
|
281
313
|
if shutil.which("uv"):
|
|
282
314
|
return ["uv", "run", tool_name]
|
|
283
|
-
|
|
284
|
-
# Fallback to direct tool (will likely fail but gives clear error)
|
|
285
315
|
return [tool_name]
|
|
286
316
|
|
|
287
317
|
@abstractmethod
|
|
@@ -243,13 +243,15 @@ class BanditTool(BaseTool):
|
|
|
243
243
|
Returns:
|
|
244
244
|
list[str]: List of command arguments.
|
|
245
245
|
"""
|
|
246
|
-
# Prefer
|
|
246
|
+
# Prefer the Bandit CLI directly; avoid module execution which can fail
|
|
247
|
+
# when Bandit isn't installed in the current venv. Fall back to uvx
|
|
248
|
+
# (which can run ephemeral tools), then finally to plain name.
|
|
247
249
|
if shutil.which("bandit"):
|
|
248
250
|
exec_cmd: list[str] = ["bandit"]
|
|
249
251
|
elif shutil.which("uvx"):
|
|
250
252
|
exec_cmd = ["uvx", "bandit"]
|
|
251
253
|
else:
|
|
252
|
-
exec_cmd =
|
|
254
|
+
exec_cmd = ["bandit"]
|
|
253
255
|
|
|
254
256
|
cmd: list[str] = exec_cmd + ["-r"]
|
|
255
257
|
|