deepy-cli 0.1.12__tar.gz → 0.1.14__tar.gz
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.
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/PKG-INFO +2 -3
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/README.md +1 -1
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/pyproject.toml +1 -2
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/__init__.py +1 -1
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/tools/builtin.py +2 -45
- deepy_cli-0.1.14/src/deepy/tools/shell_output.py +45 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/local_command.py +94 -55
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/__main__.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/cli.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/config/__init__.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/config/settings.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/data/__init__.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/data/tools/AskUserQuestion.md +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/data/tools/WebFetch.md +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/data/tools/WebSearch.md +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/data/tools/__init__.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/data/tools/edit.md +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/data/tools/modify.md +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/data/tools/read.md +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/data/tools/shell.md +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/data/tools/write.md +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/errors.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/llm/__init__.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/llm/agent.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/llm/compaction.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/llm/context.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/llm/events.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/llm/model_capabilities.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/llm/provider.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/llm/replay.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/llm/runner.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/llm/thinking.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/prompts/__init__.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/prompts/compact.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/prompts/rules.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/prompts/runtime_context.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/prompts/system.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/prompts/tool_docs.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/sessions/__init__.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/sessions/jsonl.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/sessions/manager.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/skills.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/status.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/tools/__init__.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/tools/agents.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/tools/file_state.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/tools/result.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/tools/shell_utils.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/__init__.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/app.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/ask_user_question.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/exit_summary.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/file_mentions.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/loading_text.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/markdown.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/message_view.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/model_picker.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/prompt_buffer.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/prompt_input.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/session_list.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/session_picker.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/slash_commands.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/styles.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/terminal.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/theme_picker.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/thinking_state.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/welcome.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/update_check.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/usage.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/utils/__init__.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/utils/debug_logger.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/utils/error_logger.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/utils/json.py +0 -0
- {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/utils/notify.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: deepy-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.14
|
|
4
4
|
Summary: Deepy - Vibe coding for DeepSeek models in your terminal
|
|
5
5
|
Keywords: deepseek,coding-agent,terminal,cli,agents
|
|
6
6
|
Author: kirineko
|
|
@@ -17,7 +17,6 @@ Requires-Dist: openai>=2.26,<3
|
|
|
17
17
|
Requires-Dist: orjson>=3.10,<4
|
|
18
18
|
Requires-Dist: pydantic>=2.12,<3
|
|
19
19
|
Requires-Dist: prompt-toolkit>=3.0,<4
|
|
20
|
-
Requires-Dist: pywinpty>=2.0,<3 ; sys_platform == 'win32'
|
|
21
20
|
Requires-Dist: rich>=13.9,<15
|
|
22
21
|
Requires-Dist: tiktoken>=0.9,<1
|
|
23
22
|
Requires-Dist: tomli-w>=1
|
|
@@ -79,7 +78,7 @@ of hiding tool calls behind chat text.
|
|
|
79
78
|
- **Local command mode**: type `!cmd` to run a non-interactive local shell command
|
|
80
79
|
without sending it to the model; the result is still saved as context.
|
|
81
80
|
- **Cross-platform shell handling**: POSIX shell, PowerShell, cmd, Windows paths,
|
|
82
|
-
UTF-8 output, CRLF editing, and
|
|
81
|
+
UTF-8 output, CRLF editing, and non-interactive Windows local command mode.
|
|
83
82
|
|
|
84
83
|
## See It Work
|
|
85
84
|
|
|
@@ -50,7 +50,7 @@ of hiding tool calls behind chat text.
|
|
|
50
50
|
- **Local command mode**: type `!cmd` to run a non-interactive local shell command
|
|
51
51
|
without sending it to the model; the result is still saved as context.
|
|
52
52
|
- **Cross-platform shell handling**: POSIX shell, PowerShell, cmd, Windows paths,
|
|
53
|
-
UTF-8 output, CRLF editing, and
|
|
53
|
+
UTF-8 output, CRLF editing, and non-interactive Windows local command mode.
|
|
54
54
|
|
|
55
55
|
## See It Work
|
|
56
56
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "deepy-cli"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.14"
|
|
4
4
|
description = "Deepy - Vibe coding for DeepSeek models in your terminal"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -23,7 +23,6 @@ dependencies = [
|
|
|
23
23
|
"orjson>=3.10,<4",
|
|
24
24
|
"pydantic>=2.12,<3",
|
|
25
25
|
"prompt-toolkit>=3.0,<4",
|
|
26
|
-
"pywinpty>=2.0,<3; sys_platform == 'win32'",
|
|
27
26
|
"rich>=13.9,<15",
|
|
28
27
|
"tiktoken>=0.9,<1",
|
|
29
28
|
"tomli-w>=1",
|
|
@@ -26,6 +26,7 @@ from deepy.utils import json as json_utils
|
|
|
26
26
|
|
|
27
27
|
from .file_state import FileSnippet, FileState
|
|
28
28
|
from .result import ToolResult
|
|
29
|
+
from .shell_output import decode_shell_output
|
|
29
30
|
from .shell_utils import RuntimeEnvironment
|
|
30
31
|
from .shell_utils import build_disable_extglob_command
|
|
31
32
|
from .shell_utils import build_shell_init_command
|
|
@@ -2159,54 +2160,10 @@ def _read_captured_output(stream, *, marker: str | None = None) -> tuple[str, st
|
|
|
2159
2160
|
truncated = len(data) > MAX_BASH_CAPTURE_CHARS
|
|
2160
2161
|
if truncated:
|
|
2161
2162
|
data = data[:MAX_BASH_CAPTURE_CHARS]
|
|
2162
|
-
text, encoding =
|
|
2163
|
+
text, encoding = decode_shell_output(data, marker=marker)
|
|
2163
2164
|
return text, encoding, truncated
|
|
2164
2165
|
|
|
2165
2166
|
|
|
2166
|
-
def _decode_shell_output(data: bytes, *, marker: str | None = None) -> tuple[str, str]:
|
|
2167
|
-
if not data:
|
|
2168
|
-
return "", "empty"
|
|
2169
|
-
if marker:
|
|
2170
|
-
marker_bytes = marker.encode("ascii")
|
|
2171
|
-
marker_index = data.find(marker_bytes)
|
|
2172
|
-
if marker_index >= 0:
|
|
2173
|
-
sentinel_start = marker_index
|
|
2174
|
-
if sentinel_start > 0 and data[sentinel_start - 1 : sentinel_start] == b"\n":
|
|
2175
|
-
sentinel_start -= 1
|
|
2176
|
-
if sentinel_start > 0 and data[sentinel_start - 1 : sentinel_start] == b"\r":
|
|
2177
|
-
sentinel_start -= 1
|
|
2178
|
-
visible, visible_encoding = _decode_shell_output_bytes(data[:sentinel_start])
|
|
2179
|
-
sentinel = data[sentinel_start:].decode("utf-8", errors="replace")
|
|
2180
|
-
return visible + sentinel, visible_encoding
|
|
2181
|
-
return _decode_shell_output_bytes(data)
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
def _decode_shell_output_bytes(data: bytes) -> tuple[str, str]:
|
|
2185
|
-
if not data:
|
|
2186
|
-
return "", "empty"
|
|
2187
|
-
if data.startswith((b"\xff\xfe", b"\xfe\xff")):
|
|
2188
|
-
return data.decode("utf-16", errors="replace"), "utf-16"
|
|
2189
|
-
if _looks_like_utf16le(data):
|
|
2190
|
-
return data.decode("utf-16le", errors="replace"), "utf-16le"
|
|
2191
|
-
try:
|
|
2192
|
-
return data.decode("utf-8-sig"), "utf-8-sig" if data.startswith(b"\xef\xbb\xbf") else "utf-8"
|
|
2193
|
-
except UnicodeDecodeError:
|
|
2194
|
-
pass
|
|
2195
|
-
try:
|
|
2196
|
-
return data.decode("gb18030"), "gb18030"
|
|
2197
|
-
except UnicodeDecodeError:
|
|
2198
|
-
return data.decode("utf-8", errors="replace"), "utf-8-replace"
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
def _looks_like_utf16le(data: bytes) -> bool:
|
|
2202
|
-
if len(data) < 4:
|
|
2203
|
-
return False
|
|
2204
|
-
sample = data[: min(len(data), 4096)]
|
|
2205
|
-
odd_nuls = sample[1::2].count(0)
|
|
2206
|
-
even_nuls = sample[0::2].count(0)
|
|
2207
|
-
return odd_nuls >= max(2, len(sample) // 8) and odd_nuls > even_nuls * 2
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
2167
|
def _build_shell_command(
|
|
2211
2168
|
command: str,
|
|
2212
2169
|
marker: str,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def decode_shell_output(data: bytes, *, marker: str | None = None) -> tuple[str, str]:
|
|
5
|
+
if not data:
|
|
6
|
+
return "", "empty"
|
|
7
|
+
if marker:
|
|
8
|
+
marker_bytes = marker.encode("ascii")
|
|
9
|
+
marker_index = data.find(marker_bytes)
|
|
10
|
+
if marker_index >= 0:
|
|
11
|
+
sentinel_start = marker_index
|
|
12
|
+
if sentinel_start > 0 and data[sentinel_start - 1 : sentinel_start] == b"\n":
|
|
13
|
+
sentinel_start -= 1
|
|
14
|
+
if sentinel_start > 0 and data[sentinel_start - 1 : sentinel_start] == b"\r":
|
|
15
|
+
sentinel_start -= 1
|
|
16
|
+
visible, visible_encoding = decode_shell_output_bytes(data[:sentinel_start])
|
|
17
|
+
sentinel = data[sentinel_start:].decode("utf-8", errors="replace")
|
|
18
|
+
return visible + sentinel, visible_encoding
|
|
19
|
+
return decode_shell_output_bytes(data)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def decode_shell_output_bytes(data: bytes) -> tuple[str, str]:
|
|
23
|
+
if not data:
|
|
24
|
+
return "", "empty"
|
|
25
|
+
if data.startswith((b"\xff\xfe", b"\xfe\xff")):
|
|
26
|
+
return data.decode("utf-16", errors="replace"), "utf-16"
|
|
27
|
+
if _looks_like_utf16le(data):
|
|
28
|
+
return data.decode("utf-16le", errors="replace"), "utf-16le"
|
|
29
|
+
try:
|
|
30
|
+
return data.decode("utf-8-sig"), "utf-8-sig" if data.startswith(b"\xef\xbb\xbf") else "utf-8"
|
|
31
|
+
except UnicodeDecodeError:
|
|
32
|
+
pass
|
|
33
|
+
try:
|
|
34
|
+
return data.decode("gb18030"), "gb18030"
|
|
35
|
+
except UnicodeDecodeError:
|
|
36
|
+
return data.decode("utf-8", errors="replace"), "utf-8-replace"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _looks_like_utf16le(data: bytes) -> bool:
|
|
40
|
+
if len(data) < 4:
|
|
41
|
+
return False
|
|
42
|
+
sample = data[: min(len(data), 4096)]
|
|
43
|
+
odd_nuls = sample[1::2].count(0)
|
|
44
|
+
even_nuls = sample[0::2].count(0)
|
|
45
|
+
return odd_nuls >= max(2, len(sample) // 8) and odd_nuls > even_nuls * 2
|
|
@@ -4,6 +4,7 @@ import errno
|
|
|
4
4
|
import contextlib
|
|
5
5
|
import os
|
|
6
6
|
import queue
|
|
7
|
+
import re
|
|
7
8
|
import select
|
|
8
9
|
import shutil
|
|
9
10
|
import signal
|
|
@@ -17,6 +18,7 @@ from pathlib import Path
|
|
|
17
18
|
from typing import Any
|
|
18
19
|
|
|
19
20
|
from deepy.tools.result import ToolResult
|
|
21
|
+
from deepy.tools.shell_output import decode_shell_output_bytes
|
|
20
22
|
from deepy.tools.shell_utils import RuntimeEnvironment, detect_runtime_environment
|
|
21
23
|
from deepy.utils import json as json_utils
|
|
22
24
|
|
|
@@ -29,6 +31,21 @@ DEFAULT_LOCAL_COMMAND_TIMEOUT_MS = 120_000
|
|
|
29
31
|
DEFAULT_DISPLAY_OUTPUT_LIMIT = 30_000
|
|
30
32
|
DEFAULT_CONTEXT_OUTPUT_LIMIT = 8_000
|
|
31
33
|
_TRUNCATED_MARKER = "\n... output truncated ...\n"
|
|
34
|
+
_ANSI_CONTROL_RE = re.compile(
|
|
35
|
+
r"""
|
|
36
|
+
\x1b
|
|
37
|
+
(?:
|
|
38
|
+
\[[0-?]*[ -/]*[@-~]
|
|
39
|
+
|\][^\x07\x1b]*(?:\x07|\x1b\\)
|
|
40
|
+
|P[^\x1b]*(?:\x1b\\)
|
|
41
|
+
|_[^\x1b]*(?:\x1b\\)
|
|
42
|
+
|\^[^\x1b]*(?:\x1b\\)
|
|
43
|
+
|[@-Z\\-_]
|
|
44
|
+
)
|
|
45
|
+
""",
|
|
46
|
+
re.VERBOSE,
|
|
47
|
+
)
|
|
48
|
+
_TERMINAL_CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
|
|
32
49
|
|
|
33
50
|
|
|
34
51
|
@dataclass(frozen=True)
|
|
@@ -97,7 +114,8 @@ def run_local_command(
|
|
|
97
114
|
capture_limit = max(display_limit, context_limit) + len(_TRUNCATED_MARKER)
|
|
98
115
|
|
|
99
116
|
if runtime.os_family == "windows":
|
|
100
|
-
|
|
117
|
+
_prepare_windows_process_env(process_env)
|
|
118
|
+
return _run_windows_pipes(
|
|
101
119
|
command,
|
|
102
120
|
cwd=cwd,
|
|
103
121
|
env=process_env,
|
|
@@ -155,6 +173,8 @@ def shell_tool_result_json(
|
|
|
155
173
|
output: str | None = None,
|
|
156
174
|
) -> str:
|
|
157
175
|
rendered_output = result.context_output if output is None else output
|
|
176
|
+
if result.os_family == "windows":
|
|
177
|
+
rendered_output = _sanitize_terminal_output(rendered_output)
|
|
158
178
|
metadata = _shell_metadata(result)
|
|
159
179
|
if result.ok:
|
|
160
180
|
return ToolResult.ok_result("shell", rendered_output, metadata=metadata).to_json()
|
|
@@ -270,7 +290,7 @@ def _run_posix_pty(
|
|
|
270
290
|
)
|
|
271
291
|
|
|
272
292
|
|
|
273
|
-
def
|
|
293
|
+
def _run_windows_pipes(
|
|
274
294
|
command: str,
|
|
275
295
|
*,
|
|
276
296
|
cwd: Path,
|
|
@@ -284,70 +304,56 @@ def _run_windows_pty(
|
|
|
284
304
|
started_at: float,
|
|
285
305
|
should_interrupt: Callable[[], bool] | None,
|
|
286
306
|
) -> LocalCommandResult:
|
|
287
|
-
|
|
288
|
-
from winpty import PtyProcess # type: ignore[import-not-found]
|
|
289
|
-
except Exception:
|
|
290
|
-
error = "Windows local command mode requires the pywinpty package."
|
|
291
|
-
return _error_result(
|
|
292
|
-
command,
|
|
293
|
-
cwd=cwd,
|
|
294
|
-
runtime=runtime,
|
|
295
|
-
shell_path=shell_path,
|
|
296
|
-
timeout_ms=timeout_ms,
|
|
297
|
-
started_at=started_at,
|
|
298
|
-
tty_mode="unavailable",
|
|
299
|
-
error=error,
|
|
300
|
-
)
|
|
301
|
-
|
|
302
|
-
process = None
|
|
307
|
+
process: subprocess.Popen[bytes] | None = None
|
|
303
308
|
timed_out = False
|
|
304
309
|
interrupted = False
|
|
305
|
-
captured =
|
|
310
|
+
captured = bytearray()
|
|
306
311
|
capture_truncated = False
|
|
307
|
-
output_queue: queue.Queue[
|
|
308
|
-
stop_reader = threading.Event()
|
|
312
|
+
output_queue: queue.Queue[bytes] = queue.Queue()
|
|
309
313
|
try:
|
|
310
|
-
process =
|
|
314
|
+
process = subprocess.Popen(
|
|
311
315
|
[shell_path, *_shell_args(runtime, command)],
|
|
312
316
|
cwd=str(cwd),
|
|
313
317
|
env=env,
|
|
318
|
+
stdin=subprocess.DEVNULL,
|
|
319
|
+
stdout=subprocess.PIPE,
|
|
320
|
+
stderr=subprocess.STDOUT,
|
|
321
|
+
close_fds=True,
|
|
314
322
|
)
|
|
315
|
-
reader = threading.Thread(
|
|
316
|
-
target=_read_windows_pty_output,
|
|
317
|
-
args=(process, output_queue, stop_reader),
|
|
318
|
-
daemon=True,
|
|
319
|
-
)
|
|
323
|
+
reader = threading.Thread(target=_read_pipe_output, args=(process, output_queue), daemon=True)
|
|
320
324
|
reader.start()
|
|
321
325
|
deadline = started_at + timeout_ms / 1000
|
|
322
326
|
while True:
|
|
323
|
-
|
|
327
|
+
drained_truncated = _drain_bytes_queue(
|
|
324
328
|
output_queue,
|
|
325
329
|
captured,
|
|
326
330
|
capture_limit,
|
|
327
331
|
)
|
|
328
332
|
capture_truncated = capture_truncated or drained_truncated
|
|
329
|
-
if
|
|
333
|
+
if process.poll() is not None:
|
|
330
334
|
break
|
|
331
335
|
if callable(should_interrupt) and should_interrupt():
|
|
332
336
|
interrupted = True
|
|
333
|
-
process
|
|
337
|
+
_terminate_windows_process(process)
|
|
334
338
|
break
|
|
335
339
|
remaining = deadline - time.monotonic()
|
|
336
340
|
if remaining <= 0:
|
|
337
341
|
timed_out = True
|
|
338
|
-
process
|
|
342
|
+
_terminate_windows_process(process)
|
|
339
343
|
break
|
|
340
344
|
time.sleep(min(0.05, remaining))
|
|
341
|
-
stop_reader.set()
|
|
342
345
|
reader.join(timeout=0.2)
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
346
|
+
capture_truncated = (
|
|
347
|
+
capture_truncated or _drain_bytes_queue(output_queue, captured, capture_limit)
|
|
348
|
+
)
|
|
349
|
+
if len(captured) >= capture_limit:
|
|
350
|
+
capture_truncated = True
|
|
351
|
+
exit_code = process.poll()
|
|
352
|
+
output = _sanitize_terminal_output(_decode_output(bytes(captured), windows_compatible=True))
|
|
347
353
|
error = _command_error(exit_code, timed_out=timed_out, interrupted=interrupted)
|
|
348
354
|
except Exception as exc:
|
|
349
355
|
exit_code = None
|
|
350
|
-
output = captured
|
|
356
|
+
output = _sanitize_terminal_output(_decode_output(bytes(captured), windows_compatible=True))
|
|
351
357
|
error = str(exc)
|
|
352
358
|
|
|
353
359
|
display_output, display_truncated = _limit_output(output, display_limit)
|
|
@@ -364,7 +370,7 @@ def _run_windows_pty(
|
|
|
364
370
|
command_dialect=runtime.command_dialect,
|
|
365
371
|
path_style=runtime.path_style,
|
|
366
372
|
os_family=runtime.os_family,
|
|
367
|
-
tty_mode="
|
|
373
|
+
tty_mode="pipe",
|
|
368
374
|
duration_ms=_elapsed_ms(started_at),
|
|
369
375
|
timeout_ms=timeout_ms,
|
|
370
376
|
timed_out=timed_out,
|
|
@@ -376,36 +382,37 @@ def _run_windows_pty(
|
|
|
376
382
|
)
|
|
377
383
|
|
|
378
384
|
|
|
379
|
-
def
|
|
380
|
-
process:
|
|
381
|
-
output_queue: queue.Queue[
|
|
382
|
-
stop_reader: threading.Event,
|
|
385
|
+
def _read_pipe_output(
|
|
386
|
+
process: subprocess.Popen[bytes],
|
|
387
|
+
output_queue: queue.Queue[bytes],
|
|
383
388
|
) -> None:
|
|
384
|
-
|
|
389
|
+
stream = process.stdout
|
|
390
|
+
if stream is None:
|
|
391
|
+
return
|
|
392
|
+
while True:
|
|
385
393
|
try:
|
|
386
|
-
chunk =
|
|
394
|
+
chunk = stream.read1(4096)
|
|
387
395
|
except Exception:
|
|
388
396
|
return
|
|
389
|
-
if chunk:
|
|
390
|
-
output_queue.put(chunk)
|
|
391
|
-
elif not process.isalive():
|
|
397
|
+
if not chunk:
|
|
392
398
|
return
|
|
399
|
+
output_queue.put(chunk)
|
|
393
400
|
|
|
394
401
|
|
|
395
|
-
def
|
|
396
|
-
output_queue: queue.Queue[
|
|
397
|
-
captured:
|
|
402
|
+
def _drain_bytes_queue(
|
|
403
|
+
output_queue: queue.Queue[bytes],
|
|
404
|
+
captured: bytearray,
|
|
398
405
|
capture_limit: int,
|
|
399
|
-
) ->
|
|
406
|
+
) -> bool:
|
|
400
407
|
truncated = False
|
|
401
408
|
while True:
|
|
402
409
|
try:
|
|
403
410
|
chunk = output_queue.get_nowait()
|
|
404
411
|
except queue.Empty:
|
|
405
|
-
return
|
|
412
|
+
return truncated
|
|
406
413
|
if len(captured) < capture_limit:
|
|
407
414
|
remaining = capture_limit - len(captured)
|
|
408
|
-
captured
|
|
415
|
+
captured.extend(chunk[:remaining])
|
|
409
416
|
if len(chunk) > remaining:
|
|
410
417
|
truncated = True
|
|
411
418
|
else:
|
|
@@ -472,6 +479,20 @@ def _terminate_process(process: subprocess.Popen[bytes]) -> None:
|
|
|
472
479
|
process.wait(timeout=0.2)
|
|
473
480
|
|
|
474
481
|
|
|
482
|
+
def _terminate_windows_process(process: subprocess.Popen[bytes]) -> None:
|
|
483
|
+
if process.poll() is not None:
|
|
484
|
+
return
|
|
485
|
+
with contextlib.suppress(OSError):
|
|
486
|
+
process.terminate()
|
|
487
|
+
try:
|
|
488
|
+
process.wait(timeout=0.2)
|
|
489
|
+
except subprocess.TimeoutExpired:
|
|
490
|
+
with contextlib.suppress(OSError):
|
|
491
|
+
process.kill()
|
|
492
|
+
with contextlib.suppress(OSError, subprocess.TimeoutExpired):
|
|
493
|
+
process.wait(timeout=0.2)
|
|
494
|
+
|
|
495
|
+
|
|
475
496
|
def _limit_output(output: str, limit: int) -> tuple[str, bool]:
|
|
476
497
|
if limit <= 0:
|
|
477
498
|
return (_TRUNCATED_MARKER if output else ""), bool(output)
|
|
@@ -484,8 +505,26 @@ def _limit_output(output: str, limit: int) -> tuple[str, bool]:
|
|
|
484
505
|
return output[:keep] + marker, True
|
|
485
506
|
|
|
486
507
|
|
|
487
|
-
def _decode_output(output: bytes) -> str:
|
|
488
|
-
|
|
508
|
+
def _decode_output(output: bytes, *, windows_compatible: bool = False) -> str:
|
|
509
|
+
if windows_compatible:
|
|
510
|
+
text, _ = decode_shell_output_bytes(output)
|
|
511
|
+
else:
|
|
512
|
+
text = output.decode("utf-8", errors="replace")
|
|
513
|
+
return _normalize_line_endings(text)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _prepare_windows_process_env(env: dict[str, str]) -> None:
|
|
517
|
+
env.setdefault("PYTHONUTF8", "1")
|
|
518
|
+
env.setdefault("PYTHONIOENCODING", "utf-8")
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _sanitize_terminal_output(output: str) -> str:
|
|
522
|
+
normalized = _normalize_line_endings(output)
|
|
523
|
+
return _TERMINAL_CONTROL_CHAR_RE.sub("", _ANSI_CONTROL_RE.sub("", normalized))
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _normalize_line_endings(output: str) -> str:
|
|
527
|
+
return output.replace("\r\n", "\n").replace("\r", "\n")
|
|
489
528
|
|
|
490
529
|
|
|
491
530
|
def _command_error(exit_code: int | None, *, timed_out: bool, interrupted: bool) -> str | None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|