lintro 0.5.3__py3-none-any.whl → 0.6.0__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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """Lintro - A unified CLI core for code formatting, linting, and quality assurance."""
2
2
 
3
- __version__ = "0.5.3"
3
+ __version__ = "0.6.0"
@@ -54,7 +54,7 @@ class ActionlintTableDescriptor(TableDescriptor):
54
54
  issue.level,
55
55
  issue.code or "",
56
56
  issue.message,
57
- ]
57
+ ],
58
58
  )
59
59
  return rows
60
60
 
@@ -38,7 +38,7 @@ class BanditTableDescriptor(TableDescriptor):
38
38
  f"{severity_icon} {issue.issue_severity}",
39
39
  issue.issue_confidence,
40
40
  issue.issue_text,
41
- ]
41
+ ],
42
42
  )
43
43
  return rows
44
44
 
@@ -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, rows=rows, tool_name="darglint"
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
@@ -56,7 +56,7 @@ def parse_darglint_output(output: str) -> list[DarglintIssue]:
56
56
  line=int(line_num),
57
57
  code=code,
58
58
  message=full_message,
59
- )
59
+ ),
60
60
  )
61
61
  i = j
62
62
  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 ruff JSON output into a list of RuffIssue objects.
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: The raw JSON output from ruff
28
+ output: Raw output from `ruff check --output-format json`.
18
29
 
19
30
  Returns:
20
- List of RuffIssue objects
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 == -1:
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
- for item in ruff_data:
42
- # Extract fix applicability if available
43
- fix_applicability = None
44
- if item.get("fix"):
45
- fix_applicability = item["fix"].get("applicability")
46
-
47
- issues.append(
48
- RuffIssue(
49
- file=item["filename"],
50
- line=item["location"]["row"],
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",
@@ -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
- This method provides common logic for tool executable detection.
260
- It first tries to find the tool directly in PATH, and if not found,
261
- falls back to running via 'uv run' if uv is available.
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"] # if ruff is directly available
272
+ ["uv", "run", "ruff"] # preferred when uv is available
272
273
 
273
274
  >>> self._get_executable_command("ruff")
274
- ["uv", "run", "ruff"] # if ruff not available but uv is
275
+ ["ruff"] # if uv is not available but the tool is on PATH
275
276
  """
276
- # First try direct tool execution
277
- if shutil.which(tool_name):
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
- # If tool not directly available, try via uv
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 system bandit, then `uvx bandit`, then `uv run bandit`.
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 = self._get_executable_command(tool_name="bandit")
254
+ exec_cmd = ["bandit"]
253
255
 
254
256
  cmd: list[str] = exec_cmd + ["-r"]
255
257