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.
Files changed (72) hide show
  1. codetool_shell/__init__.py +11 -0
  2. codetool_shell/api.py +59 -0
  3. codetool_shell/bin/windows-x86_64/codetool-shell-rust.exe +0 -0
  4. codetool_shell/filters/__init__.py +14 -0
  5. codetool_shell/filters/build_compiler/__init__.py +7 -0
  6. codetool_shell/filters/build_compiler/detector.py +412 -0
  7. codetool_shell/filters/build_compiler/reducer.py +166 -0
  8. codetool_shell/filters/build_compiler/summary.py +617 -0
  9. codetool_shell/filters/ci_job_log/__init__.py +7 -0
  10. codetool_shell/filters/ci_job_log/detector.py +64 -0
  11. codetool_shell/filters/ci_job_log/reducer.py +99 -0
  12. codetool_shell/filters/ci_job_log/summary.py +243 -0
  13. codetool_shell/filters/diff/__init__.py +7 -0
  14. codetool_shell/filters/diff/detector.py +136 -0
  15. codetool_shell/filters/diff/reducer.py +308 -0
  16. codetool_shell/filters/generic_log/__init__.py +7 -0
  17. codetool_shell/filters/generic_log/detector.py +175 -0
  18. codetool_shell/filters/generic_log/reducer.py +99 -0
  19. codetool_shell/filters/generic_log/summary.py +161 -0
  20. codetool_shell/filters/git.py +514 -0
  21. codetool_shell/filters/html_cleanup/__init__.py +7 -0
  22. codetool_shell/filters/html_cleanup/detector.py +136 -0
  23. codetool_shell/filters/html_cleanup/reducer.py +27 -0
  24. codetool_shell/filters/html_cleanup/summary.py +422 -0
  25. codetool_shell/filters/json_payload/__init__.py +7 -0
  26. codetool_shell/filters/json_payload/detector.py +62 -0
  27. codetool_shell/filters/json_payload/reducer.py +81 -0
  28. codetool_shell/filters/json_payload/summary.py +233 -0
  29. codetool_shell/filters/listing/__init__.py +7 -0
  30. codetool_shell/filters/listing/detector.py +294 -0
  31. codetool_shell/filters/listing/reducer.py +30 -0
  32. codetool_shell/filters/log_template/__init__.py +7 -0
  33. codetool_shell/filters/log_template/constants.py +76 -0
  34. codetool_shell/filters/log_template/detector.py +331 -0
  35. codetool_shell/filters/log_template/reducer.py +78 -0
  36. codetool_shell/filters/log_template/template.py +280 -0
  37. codetool_shell/filters/log_template/types.py +21 -0
  38. codetool_shell/filters/opaque_payload/__init__.py +7 -0
  39. codetool_shell/filters/opaque_payload/detector.py +563 -0
  40. codetool_shell/filters/opaque_payload/reducer.py +142 -0
  41. codetool_shell/filters/opaque_payload/summary.py +61 -0
  42. codetool_shell/filters/package_manager/__init__.py +7 -0
  43. codetool_shell/filters/package_manager/detector.py +220 -0
  44. codetool_shell/filters/package_manager/reducer.py +110 -0
  45. codetool_shell/filters/package_manager/summary.py +172 -0
  46. codetool_shell/filters/pipeline.py +65 -0
  47. codetool_shell/filters/rg.py +250 -0
  48. codetool_shell/filters/system_output/__init__.py +7 -0
  49. codetool_shell/filters/system_output/detector.py +600 -0
  50. codetool_shell/filters/system_output/reducer.py +331 -0
  51. codetool_shell/filters/system_output/summary.py +164 -0
  52. codetool_shell/filters/table/__init__.py +7 -0
  53. codetool_shell/filters/table/detector.py +244 -0
  54. codetool_shell/filters/table/reducer.py +57 -0
  55. codetool_shell/filters/table/summary.py +37 -0
  56. codetool_shell/filters/test_runner/__init__.py +7 -0
  57. codetool_shell/filters/test_runner/ansi.py +80 -0
  58. codetool_shell/filters/test_runner/detector.py +409 -0
  59. codetool_shell/filters/test_runner/reducer.py +288 -0
  60. codetool_shell/filters/test_runner/summary.py +449 -0
  61. codetool_shell/filters/text.py +38 -0
  62. codetool_shell/filters/traceback/__init__.py +7 -0
  63. codetool_shell/filters/traceback/detector.py +209 -0
  64. codetool_shell/filters/traceback/reducer.py +141 -0
  65. codetool_shell/filters/traceback/summary.py +122 -0
  66. codetool_shell/filters/tree.py +59 -0
  67. codetool_shell/py.typed +0 -0
  68. codetool_shell/python_backend.py +38 -0
  69. codetool_shell/rust_backend.py +254 -0
  70. codetool_shell-0.1.1.dist-info/METADATA +152 -0
  71. codetool_shell-0.1.1.dist-info/RECORD +72 -0
  72. 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)
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}")