chcode 0.1.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.
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import tempfile
5
+ import uuid
6
+ from dataclasses import dataclass
7
+
8
+ MAX_OUTPUT_LINES = 2000
9
+ MAX_OUTPUT_BYTES = 51200
10
+ MAX_PERSISTED_BYTES = 64 * 1024 * 1024
11
+
12
+
13
+ @dataclass
14
+ class TruncatedOutput:
15
+ content: str
16
+ truncated: bool = False
17
+ persisted_path: str | None = None
18
+ total_bytes: int = 0
19
+
20
+
21
+ def truncate_output(stdout: str) -> TruncatedOutput:
22
+ total_bytes = len(stdout.encode("utf-8", errors="replace"))
23
+ lines = stdout.splitlines(keepends=True)
24
+ needs_line_trunc = len(lines) > MAX_OUTPUT_LINES
25
+ needs_byte_trunc = total_bytes > MAX_OUTPUT_BYTES
26
+
27
+ if not needs_line_trunc and not needs_byte_trunc:
28
+ return TruncatedOutput(content=stdout, total_bytes=total_bytes)
29
+
30
+ persisted_path = _persist_to_file(stdout)
31
+
32
+ preview_lines = lines[:MAX_OUTPUT_LINES]
33
+ preview = "".join(preview_lines)
34
+ if len(preview.encode("utf-8", errors="replace")) > MAX_OUTPUT_BYTES:
35
+ preview = preview[:MAX_OUTPUT_BYTES]
36
+
37
+ truncation_notice = (
38
+ f"\n\n[Output truncated: {len(lines)} lines, {total_bytes} bytes total. "
39
+ f"Full output saved to: {persisted_path}]"
40
+ )
41
+
42
+ return TruncatedOutput(
43
+ content=preview + truncation_notice,
44
+ truncated=True,
45
+ persisted_path=persisted_path,
46
+ total_bytes=total_bytes,
47
+ )
48
+
49
+
50
+ def _persist_to_file(content: str) -> str:
51
+ output_dir = os.path.join(tempfile.gettempdir(), "chcode-output")
52
+ os.makedirs(output_dir, exist_ok=True)
53
+ file_id = uuid.uuid4().hex[:8]
54
+ path = os.path.join(output_dir, f"output-{file_id}.txt")
55
+
56
+ encoded = content.encode("utf-8", errors="replace")
57
+ if len(encoded) > MAX_PERSISTED_BYTES:
58
+ encoded = encoded[:MAX_PERSISTED_BYTES]
59
+
60
+ with open(path, "wb") as f:
61
+ f.write(encoded)
62
+
63
+ return path
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import os
5
+ import shutil
6
+ import tempfile
7
+ import uuid
8
+ from abc import ABC, abstractmethod
9
+
10
+
11
+ class ShellProvider(ABC):
12
+ @property
13
+ @abstractmethod
14
+ def shell_path(self) -> str: ...
15
+
16
+ @property
17
+ @abstractmethod
18
+ def is_available(self) -> bool: ...
19
+
20
+ @property
21
+ @abstractmethod
22
+ def display_name(self) -> str: ...
23
+
24
+ @abstractmethod
25
+ def build_command(self, command: str, cwd_file: str) -> str: ...
26
+
27
+ @abstractmethod
28
+ def get_spawn_args(self, command_string: str) -> list[str]: ...
29
+
30
+ def get_env_overrides(self) -> dict[str, str]:
31
+ return {}
32
+
33
+ def create_cwd_file(self) -> str:
34
+ tmpdir = tempfile.gettempdir()
35
+ file_id = uuid.uuid4().hex[:8]
36
+ return os.path.join(tmpdir, f"chcode-cwd-{file_id}")
37
+
38
+ def read_cwd_file(self, cwd_file: str) -> str | None:
39
+ try:
40
+ with open(cwd_file, encoding="utf-8-sig") as f:
41
+ return f.read().strip()
42
+ except OSError:
43
+ return None
44
+
45
+ def cleanup_cwd_file(self, cwd_file: str) -> None:
46
+ with contextlib.suppress(OSError):
47
+ os.unlink(cwd_file)
48
+
49
+
50
+ class BashProvider(ShellProvider):
51
+ def __init__(self) -> None:
52
+ self._shell = self._detect_shell()
53
+
54
+ @property
55
+ def shell_path(self) -> str:
56
+ return self._shell
57
+
58
+ @property
59
+ def is_available(self) -> bool:
60
+ return self._shell != ""
61
+
62
+ @property
63
+ def display_name(self) -> str:
64
+ return "bash"
65
+
66
+ def _detect_shell(self) -> str:
67
+ if os.name == "nt":
68
+ git_path = shutil.which("git")
69
+ if git_path:
70
+ git_bin = os.path.dirname(git_path)
71
+ candidate = os.path.join(git_bin, "bash.exe")
72
+ if os.path.isfile(candidate):
73
+ return candidate
74
+ candidate = os.path.join(git_bin, "..", "bin", "bash.exe") # pragma: no cover
75
+ if os.path.isfile(candidate): # pragma: no cover
76
+ return os.path.normpath(candidate) # pragma: no cover
77
+ bash_path = shutil.which("bash") # pragma: no cover
78
+ if bash_path and os.path.isfile(bash_path): # pragma: no cover
79
+ return bash_path # pragma: no cover
80
+ return ""
81
+ env_shell = os.environ.get("SHELL", "")
82
+ if env_shell and os.path.isfile(env_shell):
83
+ return env_shell
84
+ for candidate in ["/bin/bash", "/usr/bin/bash", "/bin/zsh", "/usr/bin/zsh"]:
85
+ if os.path.isfile(candidate):
86
+ return candidate
87
+ return shutil.which("bash") or shutil.which("zsh") or ""
88
+
89
+ def build_command(self, command: str, cwd_file: str) -> str:
90
+ escaped_cwd = cwd_file.replace("'", "'\\''")
91
+ escaped_cmd = command.replace("'", "'\\''")
92
+ return f"eval '{escaped_cmd}' && pwd -P >| '{escaped_cwd}'"
93
+
94
+ def get_spawn_args(self, command_string: str) -> list[str]:
95
+ return ["-c", command_string]
96
+
97
+
98
+ class PowerShellProvider(ShellProvider):
99
+ @property
100
+ def shell_path(self) -> str:
101
+ return "powershell"
102
+
103
+ @property
104
+ def is_available(self) -> bool:
105
+ import platform
106
+
107
+ return platform.system() == "Windows" and shutil.which("powershell") is not None
108
+
109
+ @property
110
+ def display_name(self) -> str:
111
+ return "powershell"
112
+
113
+ def build_command(self, command: str, cwd_file: str) -> str:
114
+ escaped = cwd_file.replace("'", "''")
115
+ return (
116
+ f"{command}\n"
117
+ f"; $_ec = if ($null -ne $LASTEXITCODE) {{ $LASTEXITCODE }} "
118
+ f"elseif ($?) {{ 0 }} else {{ 1 }}\n"
119
+ f"; (Get-Location).Path | Out-File -FilePath '{escaped}' "
120
+ f"-Encoding utf8 -NoNewline\n"
121
+ f"; exit $_ec"
122
+ )
123
+
124
+ def get_spawn_args(self, command_string: str) -> list[str]:
125
+ return ["-NoProfile", "-NonInteractive", "-Command", command_string]
126
+
127
+ def get_env_overrides(self) -> dict[str, str]:
128
+ return {"PSMODULEPATH": ""}
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class ShellResult:
8
+ stdout: str = ""
9
+ stderr: str = ""
10
+ exit_code: int = 0
11
+ interrupted: bool = False
12
+ timed_out: bool = False
13
+ output_file_path: str | None = None
14
+ output_file_size: int | None = None
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass
8
+ class Interpretation:
9
+ is_error: bool
10
+ message: str | None = None
11
+
12
+
13
+ _RULES: list[tuple[list[str], dict[int, str]]] = [
14
+ (["grep", "rg", "ag", "ack", "findstr", "select-string"], {1: "No matches found"}),
15
+ (["diff", "compare-object", "fc"], {1: "Files or inputs differ"}),
16
+ (["test", "["], {1: "Test condition evaluated to false"}),
17
+ (["ping"], {1: "Host unreachable or no response"}),
18
+ (["which", "where", "where.exe", "command", "get-command"], {1: "Command not found"}),
19
+ (["type", "cat", "get-content"], {1: "File not found or unreadable"}),
20
+ (["mkdir"], {1: "Directory creation failed"}),
21
+ (["robocopy"], {1: "Files copied successfully (robocopy exit 1 = success)"}),
22
+ ]
23
+
24
+
25
+ def _get_base_command(command: str) -> str:
26
+ segments = re.split(r"[|;&]", command)
27
+ last = segments[-1].strip()
28
+ for op in ["&&", "||"]:
29
+ parts = last.split(op)
30
+ last = parts[-1].strip()
31
+ tokens = last.split()
32
+ if not tokens:
33
+ return ""
34
+ base = tokens[0]
35
+ if "/" in base or "\\" in base:
36
+ base = base.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
37
+ return base.lower()
38
+
39
+
40
+ def interpret_command_result(command: str, exit_code: int) -> Interpretation:
41
+ if exit_code == 0:
42
+ return Interpretation(is_error=False)
43
+
44
+ base = _get_base_command(command)
45
+ if not base:
46
+ return Interpretation(is_error=True, message=f"Exit code {exit_code}")
47
+
48
+ for commands, exit_map in _RULES:
49
+ if base in commands or base.removesuffix("exe") in commands:
50
+ if exit_code in exit_map:
51
+ return Interpretation(is_error=False, message=exit_map[exit_code])
52
+ if 1 in exit_map and exit_code > 1:
53
+ return Interpretation(is_error=True, message=f"Exit code {exit_code}")
54
+
55
+ return Interpretation(is_error=True, message=f"Exit code {exit_code}")
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import locale
5
+ import os
6
+ import re
7
+ import signal
8
+ import subprocess
9
+
10
+ from charset_normalizer import from_bytes
11
+
12
+ from chcode.utils.shell.output import TruncatedOutput, truncate_output
13
+ from chcode.utils.shell.provider import ShellProvider
14
+ from chcode.utils.shell.result import ShellResult
15
+
16
+
17
+ class ShellSession:
18
+ def __init__(self, provider: ShellProvider) -> None:
19
+ self._provider = provider
20
+ self._cwd: str = os.getcwd()
21
+
22
+ @property
23
+ def cwd(self) -> str:
24
+ return self._cwd
25
+
26
+ @cwd.setter
27
+ def cwd(self, value: str) -> None:
28
+ if os.path.isdir(value):
29
+ self._cwd = value
30
+
31
+ @property
32
+ def provider_name(self) -> str:
33
+ return self._provider.display_name
34
+
35
+ def execute(
36
+ self,
37
+ command: str,
38
+ timeout: int | None = 120000,
39
+ workdir: str | None = None,
40
+ ) -> tuple[ShellResult, TruncatedOutput]:
41
+ cwd_file = self._provider.create_cwd_file()
42
+ full_command = self._provider.build_command(command, cwd_file)
43
+
44
+ exec_cwd = workdir or self._cwd
45
+ if not os.path.isdir(exec_cwd):
46
+ exec_cwd = self._cwd
47
+
48
+ spawn_args = self._provider.get_spawn_args(full_command)
49
+ env = {**os.environ, **self._provider.get_env_overrides()}
50
+
51
+ timeout_sec = (timeout / 1000) if timeout else None
52
+ timed_out = False
53
+
54
+ try:
55
+ proc = subprocess.Popen(
56
+ [self._provider.shell_path, *spawn_args],
57
+ stdout=subprocess.PIPE,
58
+ stderr=subprocess.PIPE,
59
+ cwd=exec_cwd,
60
+ env=env,
61
+ )
62
+ except FileNotFoundError:
63
+ return (
64
+ ShellResult(exit_code=127, stderr=f"Shell not found: {self._provider.shell_path}"),
65
+ truncate_output(""),
66
+ )
67
+ except OSError as e:
68
+ return (
69
+ ShellResult(exit_code=126, stderr=f"Failed to execute: {e}"),
70
+ truncate_output(""),
71
+ )
72
+
73
+ try:
74
+ stdout_bytes, stderr_bytes = proc.communicate(timeout=timeout_sec)
75
+ except subprocess.TimeoutExpired:
76
+ timed_out = True
77
+ with contextlib.suppress(OSError):
78
+ _kill_proc_tree(proc)
79
+ try:
80
+ stdout_bytes, stderr_bytes = proc.communicate(timeout=5)
81
+ except subprocess.TimeoutExpired:
82
+ stdout_bytes, stderr_bytes = b"", b""
83
+
84
+ stdout = _robust_decode(stdout_bytes) if stdout_bytes else ""
85
+ stderr = _robust_decode(stderr_bytes) if stderr_bytes else ""
86
+
87
+ new_cwd = self._provider.read_cwd_file(cwd_file)
88
+ if new_cwd and not workdir:
89
+ if os.name == "nt" and new_cwd.startswith("/"):
90
+ match = re.match(r"^/([a-zA-Z])(/.*)?$", new_cwd)
91
+ if match:
92
+ drive = match.group(1).upper()
93
+ rest = match.group(2) or "\\"
94
+ new_cwd = f"{drive}:{rest.replace('/', chr(92))}"
95
+ if os.path.isdir(new_cwd):
96
+ self._cwd = new_cwd
97
+
98
+ self._provider.cleanup_cwd_file(cwd_file)
99
+
100
+ result = ShellResult(
101
+ stdout=stdout,
102
+ stderr=stderr,
103
+ exit_code=proc.returncode if proc.returncode is not None else 1,
104
+ timed_out=timed_out,
105
+ interrupted=timed_out,
106
+ )
107
+
108
+ truncated = truncate_output(result.stdout)
109
+ if truncated.truncated:
110
+ result.output_file_path = truncated.persisted_path
111
+ result.output_file_size = truncated.total_bytes
112
+
113
+ return result, truncated
114
+
115
+
116
+ def _robust_decode(data: bytes) -> str:
117
+ if not data:
118
+ return ""
119
+ system_encoding = locale.getpreferredencoding() or "utf-8"
120
+ if len(data) >= 4:
121
+ bom = data[:4]
122
+ if bom[:3] == b"\xef\xbb\xbf":
123
+ return data[3:].decode("utf-8", errors="replace")
124
+ if bom[:2] in (b"\xff\xfe", b"\xfe\xff"):
125
+ return data.decode("utf-16", errors="replace")
126
+ result = from_bytes(data)
127
+ best = result.best() if result else None
128
+ if best and best.coherence > 0.5:
129
+ return str(best)
130
+ for enc in ["utf-8", "gb18030", system_encoding, "latin-1"]:
131
+ try:
132
+ return data.decode(enc, errors="strict")
133
+ except (UnicodeDecodeError, LookupError):
134
+ continue
135
+ return data.decode(system_encoding, errors="replace") # pragma: no cover
136
+
137
+
138
+ def _kill_proc_tree(proc: subprocess.Popen) -> None:
139
+ pid = proc.pid
140
+ if pid is None:
141
+ return
142
+
143
+ try:
144
+ import psutil
145
+
146
+ parent = psutil.Process(pid)
147
+ children = parent.children(recursive=True)
148
+ for child in children:
149
+ with contextlib.suppress(psutil.NoSuchProcess):
150
+ child.kill()
151
+ parent.kill()
152
+ except ImportError:
153
+ if os.name == "nt":
154
+ proc.kill()
155
+ else:
156
+ with contextlib.suppress(OSError, ProcessLookupError):
157
+ os.killpg(pid, signal.SIGKILL)
158
+ return
159
+ proc.kill()