codetool-shell 0.1.1__py3-none-win_amd64.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.
- codetool_shell/__init__.py +11 -0
- codetool_shell/api.py +59 -0
- codetool_shell/bin/windows-x86_64/codetool-shell-rust.exe +0 -0
- codetool_shell/filters/__init__.py +14 -0
- codetool_shell/filters/build_compiler/__init__.py +7 -0
- codetool_shell/filters/build_compiler/detector.py +412 -0
- codetool_shell/filters/build_compiler/reducer.py +166 -0
- codetool_shell/filters/build_compiler/summary.py +617 -0
- codetool_shell/filters/ci_job_log/__init__.py +7 -0
- codetool_shell/filters/ci_job_log/detector.py +64 -0
- codetool_shell/filters/ci_job_log/reducer.py +99 -0
- codetool_shell/filters/ci_job_log/summary.py +243 -0
- codetool_shell/filters/diff/__init__.py +7 -0
- codetool_shell/filters/diff/detector.py +136 -0
- codetool_shell/filters/diff/reducer.py +308 -0
- codetool_shell/filters/generic_log/__init__.py +7 -0
- codetool_shell/filters/generic_log/detector.py +175 -0
- codetool_shell/filters/generic_log/reducer.py +99 -0
- codetool_shell/filters/generic_log/summary.py +161 -0
- codetool_shell/filters/git.py +514 -0
- codetool_shell/filters/html_cleanup/__init__.py +7 -0
- codetool_shell/filters/html_cleanup/detector.py +136 -0
- codetool_shell/filters/html_cleanup/reducer.py +27 -0
- codetool_shell/filters/html_cleanup/summary.py +422 -0
- codetool_shell/filters/json_payload/__init__.py +7 -0
- codetool_shell/filters/json_payload/detector.py +62 -0
- codetool_shell/filters/json_payload/reducer.py +81 -0
- codetool_shell/filters/json_payload/summary.py +233 -0
- codetool_shell/filters/listing/__init__.py +7 -0
- codetool_shell/filters/listing/detector.py +294 -0
- codetool_shell/filters/listing/reducer.py +30 -0
- codetool_shell/filters/log_template/__init__.py +7 -0
- codetool_shell/filters/log_template/constants.py +76 -0
- codetool_shell/filters/log_template/detector.py +331 -0
- codetool_shell/filters/log_template/reducer.py +78 -0
- codetool_shell/filters/log_template/template.py +280 -0
- codetool_shell/filters/log_template/types.py +21 -0
- codetool_shell/filters/opaque_payload/__init__.py +7 -0
- codetool_shell/filters/opaque_payload/detector.py +563 -0
- codetool_shell/filters/opaque_payload/reducer.py +142 -0
- codetool_shell/filters/opaque_payload/summary.py +61 -0
- codetool_shell/filters/package_manager/__init__.py +7 -0
- codetool_shell/filters/package_manager/detector.py +220 -0
- codetool_shell/filters/package_manager/reducer.py +110 -0
- codetool_shell/filters/package_manager/summary.py +172 -0
- codetool_shell/filters/pipeline.py +65 -0
- codetool_shell/filters/rg.py +250 -0
- codetool_shell/filters/system_output/__init__.py +7 -0
- codetool_shell/filters/system_output/detector.py +600 -0
- codetool_shell/filters/system_output/reducer.py +331 -0
- codetool_shell/filters/system_output/summary.py +164 -0
- codetool_shell/filters/table/__init__.py +7 -0
- codetool_shell/filters/table/detector.py +244 -0
- codetool_shell/filters/table/reducer.py +57 -0
- codetool_shell/filters/table/summary.py +37 -0
- codetool_shell/filters/test_runner/__init__.py +7 -0
- codetool_shell/filters/test_runner/ansi.py +80 -0
- codetool_shell/filters/test_runner/detector.py +409 -0
- codetool_shell/filters/test_runner/reducer.py +288 -0
- codetool_shell/filters/test_runner/summary.py +449 -0
- codetool_shell/filters/text.py +38 -0
- codetool_shell/filters/traceback/__init__.py +7 -0
- codetool_shell/filters/traceback/detector.py +209 -0
- codetool_shell/filters/traceback/reducer.py +141 -0
- codetool_shell/filters/traceback/summary.py +122 -0
- codetool_shell/filters/tree.py +59 -0
- codetool_shell/py.typed +0 -0
- codetool_shell/python_backend.py +38 -0
- codetool_shell/rust_backend.py +254 -0
- codetool_shell-0.1.1.dist-info/METADATA +152 -0
- codetool_shell-0.1.1.dist-info/RECORD +72 -0
- codetool_shell-0.1.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Reducer for standalone Python traceback output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from ..text import join_preserving_final_newline, score, split_preserving_final_newline
|
|
8
|
+
from .detector import TracebackBlock, TracebackEvent, TracebackFrame, parse_python_traceback
|
|
9
|
+
from .summary import exception_type, is_repo_looking_path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class FrameGroup:
|
|
14
|
+
"""Consecutive identical frames collapsed into one group."""
|
|
15
|
+
|
|
16
|
+
frame: TracebackFrame
|
|
17
|
+
repeat_count: int = 1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def compress_traceback_output(text: str) -> str:
|
|
21
|
+
"""Compress clear standalone Python tracebacks."""
|
|
22
|
+
|
|
23
|
+
lines, final_newline = split_preserving_final_newline(text)
|
|
24
|
+
parsed = parse_python_traceback(lines)
|
|
25
|
+
if parsed is None:
|
|
26
|
+
return text
|
|
27
|
+
|
|
28
|
+
candidate_lines = _reduce_lines(lines, parsed.events)
|
|
29
|
+
if len(candidate_lines) < 4:
|
|
30
|
+
return text
|
|
31
|
+
|
|
32
|
+
candidate = join_preserving_final_newline(candidate_lines, final_newline)
|
|
33
|
+
if score(candidate) < score(text):
|
|
34
|
+
return candidate
|
|
35
|
+
return text
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _reduce_lines(lines: list[str], events: tuple[TracebackEvent, ...]) -> list[str]:
|
|
39
|
+
blocks = [event for event in events if isinstance(event, TracebackBlock)]
|
|
40
|
+
last_exception = blocks[-1].exception
|
|
41
|
+
selected = [f"python traceback: {exception_type(last_exception)}"]
|
|
42
|
+
cursor = 0
|
|
43
|
+
|
|
44
|
+
for event in events:
|
|
45
|
+
selected.extend(_reduce_noise(lines[cursor : event.start], "pre-traceback"))
|
|
46
|
+
if isinstance(event, TracebackBlock):
|
|
47
|
+
selected.extend(_reduce_block(event))
|
|
48
|
+
else:
|
|
49
|
+
selected.append(event.line)
|
|
50
|
+
cursor = event.end
|
|
51
|
+
|
|
52
|
+
selected.extend(_reduce_noise(lines[cursor:], "post-traceback"))
|
|
53
|
+
return _drop_blank_and_adjacent_duplicates(selected)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _reduce_block(block: TracebackBlock) -> list[str]:
|
|
57
|
+
selected = ["Traceback (most recent call last):"]
|
|
58
|
+
groups = _collapse_repeated_frames(block.frames)
|
|
59
|
+
selected_indices = set(_select_group_indices(groups))
|
|
60
|
+
omitted = 0
|
|
61
|
+
|
|
62
|
+
def flush_omitted() -> None:
|
|
63
|
+
nonlocal omitted
|
|
64
|
+
if omitted:
|
|
65
|
+
plural = "" if omitted == 1 else "s"
|
|
66
|
+
selected.append(f"… {omitted} stack frame{plural} omitted")
|
|
67
|
+
omitted = 0
|
|
68
|
+
|
|
69
|
+
for index, group in enumerate(groups):
|
|
70
|
+
if index not in selected_indices:
|
|
71
|
+
omitted += group.repeat_count
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
flush_omitted()
|
|
75
|
+
selected.append(group.frame.line)
|
|
76
|
+
if group.repeat_count > 1:
|
|
77
|
+
repeated = group.repeat_count - 1
|
|
78
|
+
plural = "" if repeated == 1 else "s"
|
|
79
|
+
selected.append(f"… {repeated} repeated stack frame{plural} omitted")
|
|
80
|
+
if _should_keep_context(index, selected_indices, group.frame):
|
|
81
|
+
selected.extend(group.frame.context)
|
|
82
|
+
|
|
83
|
+
flush_omitted()
|
|
84
|
+
selected.append(block.exception)
|
|
85
|
+
return selected
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _collapse_repeated_frames(frames: tuple[TracebackFrame, ...]) -> list[FrameGroup]:
|
|
89
|
+
groups: list[FrameGroup] = []
|
|
90
|
+
for frame in frames:
|
|
91
|
+
if groups and _frame_key(groups[-1].frame) == _frame_key(frame):
|
|
92
|
+
previous = groups[-1]
|
|
93
|
+
groups[-1] = FrameGroup(previous.frame, previous.repeat_count + 1)
|
|
94
|
+
continue
|
|
95
|
+
groups.append(FrameGroup(frame))
|
|
96
|
+
return groups
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _select_group_indices(groups: list[FrameGroup]) -> list[int]:
|
|
100
|
+
if len(groups) <= 8:
|
|
101
|
+
return list(range(len(groups)))
|
|
102
|
+
|
|
103
|
+
selected = {0, 1}
|
|
104
|
+
selected.update(range(max(0, len(groups) - 3), len(groups)))
|
|
105
|
+
for index, group in enumerate(groups):
|
|
106
|
+
if is_repo_looking_path(group.frame.path):
|
|
107
|
+
selected.add(index)
|
|
108
|
+
return sorted(selected)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _should_keep_context(
|
|
112
|
+
index: int,
|
|
113
|
+
selected_indices: set[int],
|
|
114
|
+
frame: TracebackFrame,
|
|
115
|
+
) -> bool:
|
|
116
|
+
if not frame.context:
|
|
117
|
+
return False
|
|
118
|
+
return index == max(selected_indices) or any("^" in line for line in frame.context)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _reduce_noise(lines: list[str], label: str) -> list[str]:
|
|
122
|
+
cleaned = [line.strip() for line in lines if line.strip()]
|
|
123
|
+
if len(cleaned) <= 3:
|
|
124
|
+
return cleaned
|
|
125
|
+
omitted = len(cleaned) - 2
|
|
126
|
+
return [cleaned[0], f"… {omitted} {label} lines omitted", cleaned[-1]]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _frame_key(frame: TracebackFrame) -> tuple[str, tuple[str, ...]]:
|
|
130
|
+
return (frame.line, frame.context)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _drop_blank_and_adjacent_duplicates(lines: list[str]) -> list[str]:
|
|
134
|
+
output: list[str] = []
|
|
135
|
+
for line in lines:
|
|
136
|
+
if not line:
|
|
137
|
+
continue
|
|
138
|
+
if output and output[-1] == line:
|
|
139
|
+
continue
|
|
140
|
+
output.append(line)
|
|
141
|
+
return output
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Line classifiers for standalone Python tracebacks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class FrameLine:
|
|
11
|
+
"""A parsed Python traceback frame line."""
|
|
12
|
+
|
|
13
|
+
line: str
|
|
14
|
+
path: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_FRAME_RE = re.compile(r'^\s*File "([^"]+)", line ([1-9]\d*)(?:, in (.+))?$')
|
|
18
|
+
_EXCEPTION_RE = re.compile(
|
|
19
|
+
r"^(?:[A-Za-z_][\w]*\.)*[A-Za-z_][\w]*"
|
|
20
|
+
r"(?:Error|Exception|Warning|Interrupt|Exit|Iteration|Timeout|Cancelled|Abort|Failure|Failed)"
|
|
21
|
+
r"(?:[:(].*)?$"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_traceback_header(line: str) -> bool:
|
|
26
|
+
"""Return true for the canonical Python traceback header."""
|
|
27
|
+
|
|
28
|
+
return line.strip() == "Traceback (most recent call last):"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_chain_separator(line: str) -> bool:
|
|
32
|
+
"""Return true for Python chained-exception separator text."""
|
|
33
|
+
|
|
34
|
+
stripped = line.strip()
|
|
35
|
+
return stripped in {
|
|
36
|
+
"During handling of the above exception, another exception occurred:",
|
|
37
|
+
"The above exception was the direct cause of the following exception:",
|
|
38
|
+
"The above exception was the context of the following exception:",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_frame_line(line: str) -> FrameLine | None:
|
|
43
|
+
"""Parse a Python traceback frame line."""
|
|
44
|
+
|
|
45
|
+
match = _FRAME_RE.match(line)
|
|
46
|
+
if match is None:
|
|
47
|
+
return None
|
|
48
|
+
return FrameLine(line=line.strip(), path=match.group(1))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_unindented_exception_summary(line: str) -> bool:
|
|
52
|
+
"""Return true for an exception summary line outside source indentation."""
|
|
53
|
+
|
|
54
|
+
if line[:1].isspace():
|
|
55
|
+
return False
|
|
56
|
+
return _EXCEPTION_RE.match(line.strip()) is not None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def exception_type(exception_line: str) -> str:
|
|
60
|
+
"""Return the exception class/type prefix from a summary line."""
|
|
61
|
+
|
|
62
|
+
return exception_line.split(":", 1)[0].split("(", 1)[0].strip()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_context_line(line: str) -> bool:
|
|
66
|
+
"""Return true for source/caret/context lines following a frame."""
|
|
67
|
+
|
|
68
|
+
stripped = line.strip()
|
|
69
|
+
return bool(line[:1].isspace() or is_previous_line_repeated(stripped))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def is_previous_line_repeated(line: str) -> bool:
|
|
73
|
+
"""Return true for CPython repeated-frame summary lines."""
|
|
74
|
+
|
|
75
|
+
stripped = line.strip()
|
|
76
|
+
return stripped.startswith("[Previous line repeated ") and stripped.endswith("]")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def normalize_context_line(line: str) -> str:
|
|
80
|
+
"""Trim traceback indentation while preserving source/caret alignment."""
|
|
81
|
+
|
|
82
|
+
if line.startswith(" "):
|
|
83
|
+
return line[4:]
|
|
84
|
+
return line.strip()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def normalize_selected_line(line: str) -> str:
|
|
88
|
+
"""Normalize a selected non-source diagnostic line."""
|
|
89
|
+
|
|
90
|
+
return line.strip()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def is_repo_looking_path(path: str) -> bool:
|
|
94
|
+
"""Return true when a frame path looks user/repo-owned rather than runtime."""
|
|
95
|
+
|
|
96
|
+
normalized = path.replace("\\", "/")
|
|
97
|
+
lower = normalized.lower()
|
|
98
|
+
if normalized.startswith("<"):
|
|
99
|
+
return True
|
|
100
|
+
if any(
|
|
101
|
+
marker in lower
|
|
102
|
+
for marker in (
|
|
103
|
+
"/site-packages/",
|
|
104
|
+
"/dist-packages/",
|
|
105
|
+
"/lib/python",
|
|
106
|
+
"/lib64/python",
|
|
107
|
+
"/python3.",
|
|
108
|
+
"/importlib/",
|
|
109
|
+
)
|
|
110
|
+
):
|
|
111
|
+
return False
|
|
112
|
+
if lower.startswith(("/usr/lib/", "/usr/local/lib/", "/opt/hostedtoolcache/")):
|
|
113
|
+
return False
|
|
114
|
+
return (
|
|
115
|
+
not normalized.startswith("/")
|
|
116
|
+
or normalized.startswith(("./", "../", "~/"))
|
|
117
|
+
or "/workspace/" in lower
|
|
118
|
+
or "/workspaces/" in lower
|
|
119
|
+
or "/src/" in lower
|
|
120
|
+
or "/tests/" in lower
|
|
121
|
+
or lower.startswith(("/home/", "/users/"))
|
|
122
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Compact path-tree rendering for filters that repeat paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class TreeRow:
|
|
10
|
+
path: str
|
|
11
|
+
suffix: str = ""
|
|
12
|
+
children: tuple[str, ...] = ()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class _TreeNode:
|
|
17
|
+
children: dict[str, "_TreeNode"] = field(default_factory=dict)
|
|
18
|
+
rows: list[TreeRow] = field(default_factory=list)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def format_tree(rows: list[TreeRow]) -> str:
|
|
22
|
+
root = _TreeNode()
|
|
23
|
+
for row in rows:
|
|
24
|
+
node = root
|
|
25
|
+
for part in _path_parts(row.path):
|
|
26
|
+
node = node.children.setdefault(part, _TreeNode())
|
|
27
|
+
node.rows.append(row)
|
|
28
|
+
|
|
29
|
+
lines: list[str] = []
|
|
30
|
+
_render_tree(root, lines, depth=0)
|
|
31
|
+
return "\n".join(lines)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _path_parts(path: str) -> list[str]:
|
|
35
|
+
clean = _clean_path(path)
|
|
36
|
+
parts = [part for part in clean.split("/") if part]
|
|
37
|
+
if clean.startswith("/") and parts:
|
|
38
|
+
parts[0] = "/" + parts[0]
|
|
39
|
+
return parts or ["."]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _clean_path(path: str) -> str:
|
|
43
|
+
clean = path.replace("\\", "/")
|
|
44
|
+
while clean.startswith("./"):
|
|
45
|
+
clean = clean[2:]
|
|
46
|
+
return clean or "."
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _render_tree(node: _TreeNode, lines: list[str], *, depth: int) -> None:
|
|
50
|
+
indent = " " * depth
|
|
51
|
+
for name, child in node.children.items():
|
|
52
|
+
if child.rows and not child.children:
|
|
53
|
+
for row in child.rows:
|
|
54
|
+
lines.append(f"{indent}{name}{row.suffix}")
|
|
55
|
+
for entry in row.children:
|
|
56
|
+
lines.append(f"{indent} {entry}")
|
|
57
|
+
else:
|
|
58
|
+
lines.append(f"{indent}{name}/")
|
|
59
|
+
_render_tree(child, lines, depth=depth + 1)
|
codetool_shell/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Pure-Python text compression backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .filters import apply_filter_pipeline
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def compress_text_python(text: str, *, max_blank_lines: int = 1) -> str:
|
|
9
|
+
"""Compress text using whitespace normalization and dedicated filters."""
|
|
10
|
+
|
|
11
|
+
normalized = _normalize_and_trim_trailing_whitespace(text)
|
|
12
|
+
filtered = apply_filter_pipeline(normalized)
|
|
13
|
+
return _collapse_blank_lines(filtered, max_blank_lines=max_blank_lines)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _normalize_and_trim_trailing_whitespace(text: str) -> str:
|
|
17
|
+
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
18
|
+
return "\n".join(line.rstrip() for line in normalized.split("\n"))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _collapse_blank_lines(text: str, *, max_blank_lines: int) -> str:
|
|
22
|
+
output_lines: list[str] = []
|
|
23
|
+
blank_run = 0
|
|
24
|
+
|
|
25
|
+
for line in text.splitlines():
|
|
26
|
+
if line == "":
|
|
27
|
+
blank_run += 1
|
|
28
|
+
if blank_run <= max_blank_lines:
|
|
29
|
+
output_lines.append("")
|
|
30
|
+
else:
|
|
31
|
+
blank_run = 0
|
|
32
|
+
output_lines.append(line)
|
|
33
|
+
|
|
34
|
+
output = "\n".join(output_lines)
|
|
35
|
+
if text.endswith("\n") and output_lines and not output.endswith("\n"):
|
|
36
|
+
output += "\n"
|
|
37
|
+
|
|
38
|
+
return output
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Wrapper for the optional Rust text-compression backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
ENV_BINARY = "CODETOOL_SHELL_RUST_BINARY"
|
|
13
|
+
ENV_TARGET_RUNTIME = "CODETOOL_SHELL_TARGET_RUNTIME"
|
|
14
|
+
ENV_TARGET_WHEEL_TAG = "CODETOOL_SHELL_TARGET_WHEEL_TAG"
|
|
15
|
+
ENV_GENERIC_TARGET_RUNTIME = "CODETOOL_TARGET_RUNTIME"
|
|
16
|
+
ENV_GENERIC_TARGET_WHEEL_TAG = "CODETOOL_TARGET_WHEEL_TAG"
|
|
17
|
+
ENV_BINARY_NAMES = (ENV_BINARY, "CODETOOL_OUTPUT_COMPRESS_RUST_BINARY")
|
|
18
|
+
ENV_TARGET_RUNTIME_NAMES = (
|
|
19
|
+
ENV_TARGET_RUNTIME,
|
|
20
|
+
"CODETOOL_OUTPUT_COMPRESS_TARGET_RUNTIME",
|
|
21
|
+
ENV_GENERIC_TARGET_RUNTIME,
|
|
22
|
+
)
|
|
23
|
+
RUST_BINARY_NAME = "codetool-shell-rust"
|
|
24
|
+
|
|
25
|
+
SUPPORTED_RUNTIME_KEYS = (
|
|
26
|
+
"linux-x86_64",
|
|
27
|
+
"linux-aarch64",
|
|
28
|
+
"macos-x86_64",
|
|
29
|
+
"macos-arm64",
|
|
30
|
+
"windows-x86_64",
|
|
31
|
+
"windows-arm64",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
RUNTIME_ALIASES = {
|
|
35
|
+
"darwin-x86_64": "macos-x86_64",
|
|
36
|
+
"darwin-amd64": "macos-x86_64",
|
|
37
|
+
"darwin-arm64": "macos-arm64",
|
|
38
|
+
"darwin-aarch64": "macos-arm64",
|
|
39
|
+
"macos-aarch64": "macos-arm64",
|
|
40
|
+
"macosx-x86_64": "macos-x86_64",
|
|
41
|
+
"macosx-arm64": "macos-arm64",
|
|
42
|
+
"linux-amd64": "linux-x86_64",
|
|
43
|
+
"linux-x64": "linux-x86_64",
|
|
44
|
+
"linux-arm64": "linux-aarch64",
|
|
45
|
+
"windows-amd64": "windows-x86_64",
|
|
46
|
+
"windows-x64": "windows-x86_64",
|
|
47
|
+
"windows-aarch64": "windows-arm64",
|
|
48
|
+
"win-x86_64": "windows-x86_64",
|
|
49
|
+
"win-amd64": "windows-x86_64",
|
|
50
|
+
"win-arm64": "windows-arm64",
|
|
51
|
+
"linux_x86_64": "linux-x86_64",
|
|
52
|
+
"linux_aarch64": "linux-aarch64",
|
|
53
|
+
"macos_x86_64": "macos-x86_64",
|
|
54
|
+
"macos_arm64": "macos-arm64",
|
|
55
|
+
"windows_x86_64": "windows-x86_64",
|
|
56
|
+
"windows_arm64": "windows-arm64",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RustBackendUnavailable(RuntimeError):
|
|
61
|
+
"""Raised when the Rust backend was requested but cannot be found."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RustBackendError(RuntimeError):
|
|
65
|
+
"""Raised when the Rust backend fails before returning compressed text."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def compress_text_rust(text: str, *, max_blank_lines: int = 1) -> str:
|
|
69
|
+
"""Compress ``text`` through the Rust CLI backend."""
|
|
70
|
+
|
|
71
|
+
binary = find_rust_binary()
|
|
72
|
+
if binary is None:
|
|
73
|
+
raise RustBackendUnavailable(
|
|
74
|
+
"Rust backend binary not found. Set "
|
|
75
|
+
f"{ENV_BINARY} or build/stage rust/{RUST_BINARY_NAME}."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
args = [
|
|
79
|
+
binary,
|
|
80
|
+
"compress",
|
|
81
|
+
"--max-blank-lines",
|
|
82
|
+
str(max_blank_lines),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
completed = subprocess.run(
|
|
87
|
+
args,
|
|
88
|
+
input=text,
|
|
89
|
+
stdout=subprocess.PIPE,
|
|
90
|
+
stderr=subprocess.PIPE,
|
|
91
|
+
text=True,
|
|
92
|
+
encoding="utf-8",
|
|
93
|
+
errors="replace",
|
|
94
|
+
timeout=30,
|
|
95
|
+
check=False,
|
|
96
|
+
)
|
|
97
|
+
except FileNotFoundError as exc:
|
|
98
|
+
raise RustBackendUnavailable(str(exc)) from exc
|
|
99
|
+
except subprocess.TimeoutExpired as exc:
|
|
100
|
+
raise RustBackendError("Rust backend process exceeded wrapper timeout") from exc
|
|
101
|
+
except OSError as exc:
|
|
102
|
+
raise RustBackendError(str(exc)) from exc
|
|
103
|
+
|
|
104
|
+
if completed.returncode != 0:
|
|
105
|
+
message = f"Rust backend failed with exit status {completed.returncode}"
|
|
106
|
+
if completed.stderr:
|
|
107
|
+
message += f": {completed.stderr.strip()}"
|
|
108
|
+
raise RustBackendError(message)
|
|
109
|
+
|
|
110
|
+
return completed.stdout
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def is_rust_available() -> bool:
|
|
114
|
+
"""Return ``True`` if a Rust backend binary can be discovered."""
|
|
115
|
+
|
|
116
|
+
return find_rust_binary() is not None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def find_rust_binary() -> Optional[str]:
|
|
120
|
+
"""Find the Rust backend via env var, packaged/dev binary, or ``PATH``."""
|
|
121
|
+
|
|
122
|
+
env_binary = _first_env(ENV_BINARY_NAMES)
|
|
123
|
+
if env_binary:
|
|
124
|
+
found = _resolve_candidate(env_binary)
|
|
125
|
+
if found is not None:
|
|
126
|
+
return found
|
|
127
|
+
|
|
128
|
+
for candidate in _packaged_and_dev_candidates():
|
|
129
|
+
found = _resolve_candidate(str(candidate))
|
|
130
|
+
if found is not None:
|
|
131
|
+
return found
|
|
132
|
+
|
|
133
|
+
return shutil.which(_platform_binary_name())
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _packaged_and_dev_candidates() -> list[Path]:
|
|
137
|
+
binary_name = _platform_binary_name()
|
|
138
|
+
package_dir = Path(__file__).resolve().parent
|
|
139
|
+
runtime_key = _target_runtime_key()
|
|
140
|
+
candidates: list[Path] = []
|
|
141
|
+
|
|
142
|
+
for key in _runtime_key_candidates(runtime_key):
|
|
143
|
+
candidates.append(package_dir / "bin" / key / binary_name)
|
|
144
|
+
|
|
145
|
+
candidates.extend(
|
|
146
|
+
[
|
|
147
|
+
package_dir / "bin" / binary_name,
|
|
148
|
+
package_dir / binary_name,
|
|
149
|
+
]
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
for parent in package_dir.parents:
|
|
153
|
+
candidates.extend(
|
|
154
|
+
[
|
|
155
|
+
parent / "rust" / "target" / "release" / binary_name,
|
|
156
|
+
parent / "rust" / "target" / "debug" / binary_name,
|
|
157
|
+
]
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return candidates
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _runtime_key_candidates(runtime_key: str) -> list[str]:
|
|
164
|
+
canonical = canonical_runtime_key(runtime_key)
|
|
165
|
+
candidates = [canonical]
|
|
166
|
+
for alias, target in RUNTIME_ALIASES.items():
|
|
167
|
+
if target == canonical and alias not in candidates:
|
|
168
|
+
candidates.append(alias)
|
|
169
|
+
return candidates
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _resolve_candidate(candidate: str) -> Optional[str]:
|
|
173
|
+
path = Path(candidate).expanduser()
|
|
174
|
+
|
|
175
|
+
if path.parent != Path("."):
|
|
176
|
+
return str(path) if _is_executable(path) else None
|
|
177
|
+
|
|
178
|
+
if path.name != candidate:
|
|
179
|
+
return str(path) if _is_executable(path) else None
|
|
180
|
+
|
|
181
|
+
return shutil.which(candidate)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _is_executable(path: Path) -> bool:
|
|
185
|
+
return path.is_file() and os.access(path, os.X_OK)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _platform_binary_name(runtime_key: str | None = None) -> str:
|
|
189
|
+
key = canonical_runtime_key(runtime_key or _target_runtime_key())
|
|
190
|
+
if key.startswith("windows-"):
|
|
191
|
+
return f"{RUST_BINARY_NAME}.exe"
|
|
192
|
+
return RUST_BINARY_NAME
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _target_runtime_key() -> str:
|
|
196
|
+
override = _first_env(ENV_TARGET_RUNTIME_NAMES)
|
|
197
|
+
if override:
|
|
198
|
+
return canonical_runtime_key(override)
|
|
199
|
+
return _runtime_key_for_system(platform.system(), platform.machine())
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _platform_tag() -> str:
|
|
203
|
+
"""Compatibility helper for tests/consumers of the runtime directory key."""
|
|
204
|
+
|
|
205
|
+
return _target_runtime_key()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def canonical_runtime_key(key: str) -> str:
|
|
209
|
+
normalized = key.strip().lower().replace("_", "-")
|
|
210
|
+
normalized = normalized.replace("linux-x86-64", "linux-x86_64")
|
|
211
|
+
normalized = normalized.replace("macos-x86-64", "macos-x86_64")
|
|
212
|
+
normalized = normalized.replace("windows-x86-64", "windows-x86_64")
|
|
213
|
+
normalized = normalized.replace("darwin-x86-64", "darwin-x86_64")
|
|
214
|
+
normalized = RUNTIME_ALIASES.get(normalized, normalized)
|
|
215
|
+
if normalized not in SUPPORTED_RUNTIME_KEYS:
|
|
216
|
+
raise ValueError(
|
|
217
|
+
"unsupported shell runtime key "
|
|
218
|
+
f"{key!r}; expected one of: {', '.join(SUPPORTED_RUNTIME_KEYS)}"
|
|
219
|
+
)
|
|
220
|
+
return normalized
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _first_env(names: tuple[str, ...]) -> str | None:
|
|
224
|
+
for name in names:
|
|
225
|
+
value = os.environ.get(name)
|
|
226
|
+
if value:
|
|
227
|
+
return value
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _runtime_key_for_system(system: str, machine: str) -> str:
|
|
232
|
+
normalized_system = (system or "").strip().lower()
|
|
233
|
+
normalized_machine = (machine or "").strip().lower().replace("_", "-")
|
|
234
|
+
|
|
235
|
+
os_key = {
|
|
236
|
+
"linux": "linux",
|
|
237
|
+
"darwin": "macos",
|
|
238
|
+
"macos": "macos",
|
|
239
|
+
"windows": "windows",
|
|
240
|
+
"mingw": "windows",
|
|
241
|
+
"msys": "windows",
|
|
242
|
+
"cygwin": "windows",
|
|
243
|
+
}.get(normalized_system, normalized_system)
|
|
244
|
+
|
|
245
|
+
arch_key = {
|
|
246
|
+
"amd64": "x86_64",
|
|
247
|
+
"x64": "x86_64",
|
|
248
|
+
"x86-64": "x86_64",
|
|
249
|
+
"x86_64": "x86_64",
|
|
250
|
+
"arm64": "arm64",
|
|
251
|
+
"aarch64": "aarch64" if os_key == "linux" else "arm64",
|
|
252
|
+
}.get(normalized_machine, normalized_machine)
|
|
253
|
+
|
|
254
|
+
return canonical_runtime_key(f"{os_key}-{arch_key}")
|