bitool 0.1.2__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.
- bitool/__init__.py +27 -0
- bitool/cmd/__init__.py +65 -0
- bitool/cmd/_base.py +105 -0
- bitool/cmd/_condition.py +60 -0
- bitool/cmd/_scheduler.py +548 -0
- bitool/cmd/env.py +454 -0
- bitool/cmd/git.py +123 -0
- bitool/cmd/io.py +248 -0
- bitool/cmd/pdf.py +385 -0
- bitool/cmd/run.py +300 -0
- bitool/cmd/toml.py +237 -0
- bitool/cmd/version.py +630 -0
- bitool/consts.py +14 -0
- bitool/core/__init__.py +7 -0
- bitool/core/app.py +142 -0
- bitool/core/commands.py +194 -0
- bitool/core/config.py +647 -0
- bitool/core/env.py +18 -0
- bitool/core/logger.py +237 -0
- bitool/core/plugin.py +117 -0
- bitool/core/workspace.py +76 -0
- bitool/models/__init__.py +3 -0
- bitool/models/version.py +173 -0
- bitool/scripts/__init__.py +1 -0
- bitool/scripts/bumpversion.py +189 -0
- bitool/scripts/clearscreen.py +37 -0
- bitool/scripts/envpy.py +161 -0
- bitool/scripts/envrs.py +119 -0
- bitool/scripts/filedate.py +246 -0
- bitool/scripts/filelevel.py +191 -0
- bitool/scripts/gittool.py +178 -0
- bitool/scripts/img2pdf.py +151 -0
- bitool/scripts/pdf2img.py +139 -0
- bitool/scripts/piptool.py +130 -0
- bitool/scripts/pymake.py +345 -0
- bitool/scripts/sshcopyid.py +491 -0
- bitool/scripts/taskkill.py +366 -0
- bitool/scripts/which.py +227 -0
- bitool/types.py +7 -0
- bitool/utils/__init__.py +9 -0
- bitool/utils/cli_parser.py +412 -0
- bitool/utils/executor.py +881 -0
- bitool/utils/profiler.py +369 -0
- bitool/utils/task.py +133 -0
- bitool/utils/task_group.py +668 -0
- bitool/utils/tests/__init__.py +0 -0
- bitool/utils/tests/test_profiler.py +487 -0
- bitool-0.1.2.dist-info/METADATA +154 -0
- bitool-0.1.2.dist-info/RECORD +51 -0
- bitool-0.1.2.dist-info/WHEEL +4 -0
- bitool-0.1.2.dist-info/entry_points.txt +15 -0
bitool/utils/executor.py
ADDED
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
"""子进程命令执行模块.
|
|
2
|
+
|
|
3
|
+
本模块提供一个智能的子进程执行器,根据系统 CPU 配置自动选择最优的
|
|
4
|
+
命令执行方式,并将所有输出记录到日志系统中.
|
|
5
|
+
|
|
6
|
+
核心功能
|
|
7
|
+
--------
|
|
8
|
+
- 根据 CPU 核心数自动选择串行/并行执行策略
|
|
9
|
+
- 实时捕获并记录命令的标准输出和标准错误
|
|
10
|
+
- 支持超时控制和进程管理
|
|
11
|
+
- 线程安全的执行环境
|
|
12
|
+
- 可扩展的命令执行策略
|
|
13
|
+
|
|
14
|
+
快速开始
|
|
15
|
+
--------
|
|
16
|
+
>>> from pytola_core.command import execute
|
|
17
|
+
>>> result = execute(["ffmpeg", "-i", "input.mp4", "output.avi"])
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import subprocess
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from enum import Enum
|
|
27
|
+
from io import TextIOBase
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Callable, List, Sequence, Union, overload
|
|
30
|
+
|
|
31
|
+
from ..core import ConfigMixin, logger
|
|
32
|
+
|
|
33
|
+
__all__ = ["execute"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RunMode(str, Enum):
|
|
37
|
+
"""执行模式常量."""
|
|
38
|
+
|
|
39
|
+
SERIAL = "serial"
|
|
40
|
+
CONCURRENT = "concurrent"
|
|
41
|
+
STREAMING = "streaming"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _DefaultExecutorConfig(ConfigMixin):
|
|
45
|
+
"""默认执行器配置."""
|
|
46
|
+
|
|
47
|
+
TIMEOUT: int = 30 # 默认超时时间(秒)
|
|
48
|
+
STREAM_CHUNK_SIZE: int = 1024
|
|
49
|
+
ENABLE_STREAMING: bool = True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_conf = _DefaultExecutorConfig()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class RunResult:
|
|
57
|
+
"""命令执行结果.
|
|
58
|
+
|
|
59
|
+
Attributes
|
|
60
|
+
----------
|
|
61
|
+
returncode : int
|
|
62
|
+
命令返回码,0 表示成功
|
|
63
|
+
stdout : str
|
|
64
|
+
标准输出内容
|
|
65
|
+
stderr : str
|
|
66
|
+
标准错误内容
|
|
67
|
+
execution_time : float
|
|
68
|
+
执行耗时(秒)
|
|
69
|
+
mode : str
|
|
70
|
+
使用的执行模式
|
|
71
|
+
cmd : list[str]
|
|
72
|
+
执行的命令
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
returncode: int
|
|
76
|
+
stdout: str
|
|
77
|
+
stderr: str
|
|
78
|
+
execution_time: float
|
|
79
|
+
mode: str = RunMode.SERIAL
|
|
80
|
+
cmd: list[str] = field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def success(self) -> bool:
|
|
84
|
+
"""命令是否执行成功."""
|
|
85
|
+
return self.returncode == 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# 类型别名 (使用 Union 以兼容 Python 3.8)
|
|
89
|
+
ProcessArgs = Sequence[Union[List[str], Callable[[], RunResult]]]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ProcessExecutor:
|
|
93
|
+
"""智能命令执行器.
|
|
94
|
+
|
|
95
|
+
根据系统 CPU 配置自动选择最优执行策略,并将输出记录到日志系统.
|
|
96
|
+
|
|
97
|
+
Parameters
|
|
98
|
+
----------
|
|
99
|
+
long_running_cmds : set[str], optional
|
|
100
|
+
自定义长时间运行命令集合,默认使用内置集合
|
|
101
|
+
|
|
102
|
+
Examples
|
|
103
|
+
--------
|
|
104
|
+
>>> executor = ProcessExecutor()
|
|
105
|
+
>>> result = executor.run(["ls", "-la"])
|
|
106
|
+
>>> if result.success:
|
|
107
|
+
... print("命令执行成功")
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def _select_execution_mode(self, cmd: list[str]) -> str:
|
|
111
|
+
"""根据系统配置选择执行模式.
|
|
112
|
+
|
|
113
|
+
所有命令统一使用流式模式,实时显示执行进展.
|
|
114
|
+
|
|
115
|
+
Parameters
|
|
116
|
+
----------
|
|
117
|
+
cmd : list[str]
|
|
118
|
+
命令及其参数列表
|
|
119
|
+
|
|
120
|
+
Returns
|
|
121
|
+
-------
|
|
122
|
+
str
|
|
123
|
+
选择的执行模式
|
|
124
|
+
"""
|
|
125
|
+
if not cmd:
|
|
126
|
+
return RunMode.SERIAL
|
|
127
|
+
|
|
128
|
+
# 所有命令统一使用流式模式,实时显示输出
|
|
129
|
+
return RunMode.STREAMING
|
|
130
|
+
|
|
131
|
+
def run(
|
|
132
|
+
self,
|
|
133
|
+
cmd: list[str],
|
|
134
|
+
cwd: Path | None = None,
|
|
135
|
+
env: dict[str, str] | None = None,
|
|
136
|
+
timeout: int | None = None,
|
|
137
|
+
*,
|
|
138
|
+
check: bool = False,
|
|
139
|
+
capture_output: bool = True,
|
|
140
|
+
text: bool = True,
|
|
141
|
+
) -> RunResult:
|
|
142
|
+
"""执行命令并返回结果.
|
|
143
|
+
|
|
144
|
+
Parameters
|
|
145
|
+
----------
|
|
146
|
+
cmd : list[str]
|
|
147
|
+
命令及其参数列表
|
|
148
|
+
cwd : Path, optional
|
|
149
|
+
工作目录
|
|
150
|
+
env : dict[str, str], optional
|
|
151
|
+
环境变量
|
|
152
|
+
timeout : int, optional
|
|
153
|
+
超时时间(秒),默认使用实例配置
|
|
154
|
+
check : bool, optional
|
|
155
|
+
是否检查返回码,非零时抛出异常
|
|
156
|
+
capture_output : bool, optional
|
|
157
|
+
是否捕获输出
|
|
158
|
+
text : bool, optional
|
|
159
|
+
是否以文本模式处理输出
|
|
160
|
+
|
|
161
|
+
Returns
|
|
162
|
+
-------
|
|
163
|
+
RunResult
|
|
164
|
+
命令执行结果
|
|
165
|
+
|
|
166
|
+
Raises
|
|
167
|
+
------
|
|
168
|
+
subprocess.TimeoutExpired
|
|
169
|
+
命令执行超时
|
|
170
|
+
subprocess.CalledProcessError
|
|
171
|
+
命令返回非零退出码(当 check=True 时)
|
|
172
|
+
ValueError
|
|
173
|
+
命令列表为空
|
|
174
|
+
"""
|
|
175
|
+
if not cmd:
|
|
176
|
+
msg = "命令列表不能为空"
|
|
177
|
+
raise ValueError(msg)
|
|
178
|
+
|
|
179
|
+
effective_timeout: int = timeout if timeout is not None else _conf.TIMEOUT
|
|
180
|
+
mode: str = self._select_execution_mode(cmd=cmd)
|
|
181
|
+
|
|
182
|
+
logger.info(
|
|
183
|
+
f"开始执行命令: `{' '.join(cmd)}`, 模式:{mode}, 超时:{effective_timeout}秒"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if mode == RunMode.STREAMING:
|
|
187
|
+
return self._run_streaming(
|
|
188
|
+
cmd,
|
|
189
|
+
cwd,
|
|
190
|
+
env,
|
|
191
|
+
effective_timeout,
|
|
192
|
+
check=check,
|
|
193
|
+
capture_output=capture_output,
|
|
194
|
+
text=text,
|
|
195
|
+
)
|
|
196
|
+
elif mode == RunMode.CONCURRENT:
|
|
197
|
+
return self._run_concurrent(
|
|
198
|
+
cmd,
|
|
199
|
+
cwd,
|
|
200
|
+
env,
|
|
201
|
+
effective_timeout,
|
|
202
|
+
check=check,
|
|
203
|
+
capture_output=capture_output,
|
|
204
|
+
text=text,
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
return self._run_serial(
|
|
208
|
+
cmd,
|
|
209
|
+
cwd,
|
|
210
|
+
env,
|
|
211
|
+
effective_timeout,
|
|
212
|
+
check=check,
|
|
213
|
+
capture_output=capture_output,
|
|
214
|
+
text=text,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def _run_serial(
|
|
218
|
+
self,
|
|
219
|
+
cmd: list[str],
|
|
220
|
+
cwd: Path | None,
|
|
221
|
+
env: dict[str, str] | None,
|
|
222
|
+
timeout: int,
|
|
223
|
+
*,
|
|
224
|
+
check: bool,
|
|
225
|
+
capture_output: bool,
|
|
226
|
+
text: bool,
|
|
227
|
+
) -> RunResult:
|
|
228
|
+
"""串行执行命令.
|
|
229
|
+
|
|
230
|
+
Parameters
|
|
231
|
+
----------
|
|
232
|
+
cmd : list[str]
|
|
233
|
+
命令及其参数列表
|
|
234
|
+
cwd : Path, optional
|
|
235
|
+
工作目录
|
|
236
|
+
env : dict[str, str], optional
|
|
237
|
+
环境变量
|
|
238
|
+
timeout : int
|
|
239
|
+
超时时间(秒)
|
|
240
|
+
check : bool
|
|
241
|
+
是否检查返回码
|
|
242
|
+
capture_output : bool
|
|
243
|
+
是否捕获输出
|
|
244
|
+
text : bool
|
|
245
|
+
是否以文本模式处理输出
|
|
246
|
+
|
|
247
|
+
Returns
|
|
248
|
+
-------
|
|
249
|
+
RunResult
|
|
250
|
+
命令执行结果
|
|
251
|
+
"""
|
|
252
|
+
start_time = time.time()
|
|
253
|
+
stdout_lines: list[str] = []
|
|
254
|
+
stderr_lines: list[str] = []
|
|
255
|
+
execution_time: float = 0.0
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
# 使用 Popen 实现实时输出
|
|
259
|
+
# 注意:行缓冲(bufsize=1)仅在 text=True 时有效
|
|
260
|
+
process = subprocess.Popen( # noqa: S603
|
|
261
|
+
cmd,
|
|
262
|
+
cwd=cwd,
|
|
263
|
+
env=env,
|
|
264
|
+
stdout=subprocess.PIPE,
|
|
265
|
+
stderr=subprocess.PIPE,
|
|
266
|
+
text=text,
|
|
267
|
+
bufsize=1 if text else -1, # 行缓冲(仅文本模式)或使用默认缓冲
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# 启动线程读取输出
|
|
271
|
+
stdout_thread = threading.Thread(
|
|
272
|
+
target=self._read_stream,
|
|
273
|
+
args=(process.stdout, stdout_lines, "STDOUT"),
|
|
274
|
+
daemon=True,
|
|
275
|
+
)
|
|
276
|
+
stderr_thread = threading.Thread(
|
|
277
|
+
target=self._read_stream,
|
|
278
|
+
args=(process.stderr, stderr_lines, "STDERR"),
|
|
279
|
+
daemon=True,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
stdout_thread.start()
|
|
283
|
+
stderr_thread.start()
|
|
284
|
+
|
|
285
|
+
# 等待进程完成或超时
|
|
286
|
+
returncode: int = process.wait(timeout=timeout)
|
|
287
|
+
|
|
288
|
+
# 等待输出读取完成(设置合理的超时避免无限等待)
|
|
289
|
+
stdout_thread.join(timeout=5)
|
|
290
|
+
stderr_thread.join(timeout=5)
|
|
291
|
+
|
|
292
|
+
execution_time = time.time() - start_time
|
|
293
|
+
|
|
294
|
+
stdout_content = "\n".join(stdout_lines)
|
|
295
|
+
stderr_content = "\n".join(stderr_lines)
|
|
296
|
+
|
|
297
|
+
log_msg = f"命令执行完成: `{' '.join(cmd)}`, 返回码:{returncode}, 耗时:{execution_time:.2f}秒`"
|
|
298
|
+
|
|
299
|
+
# uv 工具特殊处理:返回码 2 表示有警告但成功
|
|
300
|
+
is_uv_command = cmd and cmd[0] == "uv"
|
|
301
|
+
is_uv_success = is_uv_command and returncode == 2
|
|
302
|
+
|
|
303
|
+
if returncode == 0 or is_uv_success:
|
|
304
|
+
logger.info(log_msg)
|
|
305
|
+
# uv 返回码 2 时添加警告提示
|
|
306
|
+
if is_uv_success:
|
|
307
|
+
logger.warning("uv 命令有警告信息,请检查上方输出")
|
|
308
|
+
# 已实时输出, 不再重复输出
|
|
309
|
+
else:
|
|
310
|
+
# 根据返回码决定日志级别
|
|
311
|
+
if returncode < 0:
|
|
312
|
+
logger.error(log_msg)
|
|
313
|
+
else:
|
|
314
|
+
logger.warning(log_msg)
|
|
315
|
+
|
|
316
|
+
# 失败时显示错误输出(排除 uv 的进度信息)
|
|
317
|
+
if stderr_content:
|
|
318
|
+
for line in stderr_content.strip().split("\n"):
|
|
319
|
+
if line.strip():
|
|
320
|
+
# uv 的进度信息不算错误
|
|
321
|
+
if is_uv_command and any(
|
|
322
|
+
keyword in line
|
|
323
|
+
for keyword in [
|
|
324
|
+
"Resolved",
|
|
325
|
+
"Building",
|
|
326
|
+
"Built",
|
|
327
|
+
"Prepared",
|
|
328
|
+
]
|
|
329
|
+
):
|
|
330
|
+
logger.info(f" uv 进度: `{line.strip()}`")
|
|
331
|
+
else:
|
|
332
|
+
logger.error(f" 错误: `{line.strip()}`")
|
|
333
|
+
elif stdout_content:
|
|
334
|
+
for line in stdout_content.strip().split("\n"):
|
|
335
|
+
if line.strip():
|
|
336
|
+
logger.warning(f" 输出: `{line.strip()}`")
|
|
337
|
+
|
|
338
|
+
if check and returncode != 0:
|
|
339
|
+
self._raise_on_failure(returncode, cmd, stdout_content, stderr_content)
|
|
340
|
+
|
|
341
|
+
return RunResult(
|
|
342
|
+
returncode=returncode,
|
|
343
|
+
stdout=stdout_content,
|
|
344
|
+
stderr=stderr_content,
|
|
345
|
+
execution_time=execution_time,
|
|
346
|
+
mode=RunMode.SERIAL,
|
|
347
|
+
cmd=cmd,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
except subprocess.TimeoutExpired:
|
|
351
|
+
execution_time = time.time() - start_time
|
|
352
|
+
logger.error(f"命令执行超时: {' '.join(cmd)}, 超时时间:{timeout}秒")
|
|
353
|
+
raise
|
|
354
|
+
except FileNotFoundError as e:
|
|
355
|
+
execution_time = time.time() - start_time
|
|
356
|
+
logger.error(f"命令未找到: {' '.join(cmd)}, 错误:{e}")
|
|
357
|
+
raise
|
|
358
|
+
except (OSError, RuntimeError, ValueError) as e:
|
|
359
|
+
execution_time = time.time() - start_time
|
|
360
|
+
logger.error(f"命令执行异常: {' '.join(cmd)}, 错误:{e}", exc_info=True)
|
|
361
|
+
raise
|
|
362
|
+
|
|
363
|
+
def _run_concurrent(
|
|
364
|
+
self,
|
|
365
|
+
cmd: list[str],
|
|
366
|
+
cwd: Path | None,
|
|
367
|
+
env: dict[str, str] | None,
|
|
368
|
+
timeout: int,
|
|
369
|
+
*,
|
|
370
|
+
check: bool,
|
|
371
|
+
capture_output: bool,
|
|
372
|
+
text: bool,
|
|
373
|
+
) -> RunResult:
|
|
374
|
+
"""并发执行命令(使用线程池优化).
|
|
375
|
+
|
|
376
|
+
注意: 对于单个命令,并发模式与串行模式相同.
|
|
377
|
+
此方法预留接口用于未来批量命令执行优化.
|
|
378
|
+
|
|
379
|
+
Parameters
|
|
380
|
+
----------
|
|
381
|
+
cmd : list[str]
|
|
382
|
+
命令及其参数列表
|
|
383
|
+
cwd : Path, optional
|
|
384
|
+
工作目录
|
|
385
|
+
env : dict[str, str], optional
|
|
386
|
+
环境变量
|
|
387
|
+
timeout : int
|
|
388
|
+
超时时间(秒)
|
|
389
|
+
check : bool
|
|
390
|
+
是否检查返回码
|
|
391
|
+
capture_output : bool
|
|
392
|
+
是否捕获输出
|
|
393
|
+
text : bool
|
|
394
|
+
是否以文本模式处理输出
|
|
395
|
+
|
|
396
|
+
Returns
|
|
397
|
+
-------
|
|
398
|
+
RunResult
|
|
399
|
+
命令执行结果
|
|
400
|
+
"""
|
|
401
|
+
return self._run_serial(
|
|
402
|
+
cmd,
|
|
403
|
+
cwd,
|
|
404
|
+
env,
|
|
405
|
+
timeout,
|
|
406
|
+
check=check,
|
|
407
|
+
capture_output=capture_output,
|
|
408
|
+
text=text,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
def _run_streaming(
|
|
412
|
+
self,
|
|
413
|
+
cmd: list[str],
|
|
414
|
+
cwd: Path | None,
|
|
415
|
+
env: dict[str, str] | None,
|
|
416
|
+
timeout: int,
|
|
417
|
+
*,
|
|
418
|
+
check: bool,
|
|
419
|
+
capture_output: bool,
|
|
420
|
+
text: bool,
|
|
421
|
+
) -> RunResult:
|
|
422
|
+
"""流式执行命令,实时捕获输出.
|
|
423
|
+
|
|
424
|
+
Parameters
|
|
425
|
+
----------
|
|
426
|
+
cmd : list[str]
|
|
427
|
+
命令及其参数列表
|
|
428
|
+
cwd : Path, optional
|
|
429
|
+
工作目录
|
|
430
|
+
env : dict[str, str], optional
|
|
431
|
+
环境变量
|
|
432
|
+
timeout : int
|
|
433
|
+
超时时间(秒)
|
|
434
|
+
check : bool
|
|
435
|
+
是否检查返回码
|
|
436
|
+
capture_output : bool
|
|
437
|
+
是否捕获输出
|
|
438
|
+
text : bool
|
|
439
|
+
是否以文本模式处理输出
|
|
440
|
+
|
|
441
|
+
Returns
|
|
442
|
+
-------
|
|
443
|
+
RunResult
|
|
444
|
+
命令执行结果
|
|
445
|
+
"""
|
|
446
|
+
start_time = time.time()
|
|
447
|
+
stdout_lines: list[str] = []
|
|
448
|
+
stderr_lines: list[str] = []
|
|
449
|
+
execution_time: float = 0.0
|
|
450
|
+
|
|
451
|
+
try:
|
|
452
|
+
process = subprocess.Popen( # noqa: S603
|
|
453
|
+
cmd,
|
|
454
|
+
cwd=cwd,
|
|
455
|
+
env=env,
|
|
456
|
+
stdout=subprocess.PIPE,
|
|
457
|
+
stderr=subprocess.PIPE,
|
|
458
|
+
text=text,
|
|
459
|
+
bufsize=1 if text else -1, # 行缓冲(仅文本模式)或使用默认缓冲
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
# 启动线程读取输出
|
|
463
|
+
stdout_thread = threading.Thread(
|
|
464
|
+
target=self._read_stream,
|
|
465
|
+
args=(process.stdout, stdout_lines, "STDOUT"),
|
|
466
|
+
daemon=True,
|
|
467
|
+
)
|
|
468
|
+
stderr_thread = threading.Thread(
|
|
469
|
+
target=self._read_stream,
|
|
470
|
+
args=(process.stderr, stderr_lines, "STDERR"),
|
|
471
|
+
daemon=True,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
stdout_thread.start()
|
|
475
|
+
stderr_thread.start()
|
|
476
|
+
|
|
477
|
+
# 等待进程完成或超时
|
|
478
|
+
try:
|
|
479
|
+
returncode: int = process.wait(timeout=timeout)
|
|
480
|
+
except subprocess.TimeoutExpired:
|
|
481
|
+
process.kill()
|
|
482
|
+
execution_time = time.time() - start_time
|
|
483
|
+
logger.error(
|
|
484
|
+
f"流式命令执行超时: `{' '.join(cmd)}, 超时时间:{timeout}秒`"
|
|
485
|
+
)
|
|
486
|
+
raise
|
|
487
|
+
|
|
488
|
+
# 等待输出读取完成(设置合理的超时避免无限等待)
|
|
489
|
+
stdout_thread.join(timeout=5)
|
|
490
|
+
stderr_thread.join(timeout=5)
|
|
491
|
+
|
|
492
|
+
# 如果线程仍在运行,记录警告(daemon 线程会在主线程退出时自动终止)
|
|
493
|
+
if stdout_thread.is_alive() or stderr_thread.is_alive():
|
|
494
|
+
logger.warning("输出读取线程未在预期时间内完成")
|
|
495
|
+
|
|
496
|
+
execution_time = time.time() - start_time
|
|
497
|
+
|
|
498
|
+
stdout_content = "\n".join(stdout_lines)
|
|
499
|
+
stderr_content = "\n".join(stderr_lines)
|
|
500
|
+
|
|
501
|
+
log_msg = f"流式命令执行完成: `{' '.join(cmd)}, 返回码:{returncode}, 耗时:{execution_time:.2f}秒`"
|
|
502
|
+
|
|
503
|
+
# uv 工具特殊处理:返回码 2 表示有警告但成功
|
|
504
|
+
is_uv_command = cmd and cmd[0] == "uv"
|
|
505
|
+
is_uv_success = is_uv_command and returncode == 2
|
|
506
|
+
|
|
507
|
+
if returncode == 0 or is_uv_success:
|
|
508
|
+
logger.info(log_msg)
|
|
509
|
+
# uv 返回码 2 时添加警告提示
|
|
510
|
+
if is_uv_success:
|
|
511
|
+
logger.warning("uv 命令有警告信息,请检查上方输出")
|
|
512
|
+
# 流式模式已实时输出, 不再重复输出
|
|
513
|
+
else:
|
|
514
|
+
# 根据返回码决定日志级别
|
|
515
|
+
if returncode < 0:
|
|
516
|
+
logger.error(log_msg)
|
|
517
|
+
else:
|
|
518
|
+
logger.warning(log_msg)
|
|
519
|
+
|
|
520
|
+
# 失败时显示错误输出(排除 uv 的进度信息)
|
|
521
|
+
if stderr_content:
|
|
522
|
+
for line in stderr_content.strip().split("\n"):
|
|
523
|
+
if line.strip():
|
|
524
|
+
# uv 的进度信息不算错误
|
|
525
|
+
if is_uv_command and any(
|
|
526
|
+
keyword in line
|
|
527
|
+
for keyword in [
|
|
528
|
+
"Resolved",
|
|
529
|
+
"Building",
|
|
530
|
+
"Built",
|
|
531
|
+
"Prepared",
|
|
532
|
+
]
|
|
533
|
+
):
|
|
534
|
+
logger.info(f" uv 进度: `{line.strip()}`")
|
|
535
|
+
else:
|
|
536
|
+
logger.error(f" 错误: `{line.strip()}`")
|
|
537
|
+
elif stdout_content:
|
|
538
|
+
for line in stdout_content.strip().split("\n"):
|
|
539
|
+
if line.strip():
|
|
540
|
+
logger.warning(f" 输出: `{line.strip()}`")
|
|
541
|
+
|
|
542
|
+
if check and returncode != 0:
|
|
543
|
+
self._raise_on_failure(returncode, cmd, stdout_content, stderr_content)
|
|
544
|
+
|
|
545
|
+
return RunResult(
|
|
546
|
+
returncode=returncode,
|
|
547
|
+
stdout=stdout_content,
|
|
548
|
+
stderr=stderr_content,
|
|
549
|
+
execution_time=execution_time,
|
|
550
|
+
mode=RunMode.STREAMING,
|
|
551
|
+
cmd=cmd,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
except FileNotFoundError as e:
|
|
555
|
+
execution_time = time.time() - start_time
|
|
556
|
+
logger.error(f"流式命令未找到: `{' '.join(cmd)}, 错误:{e}`")
|
|
557
|
+
raise
|
|
558
|
+
except (OSError, RuntimeError, ValueError) as e:
|
|
559
|
+
execution_time = time.time() - start_time
|
|
560
|
+
logger.error(
|
|
561
|
+
f"流式命令执行异常: `{' '.join(cmd)}, 错误:{e}`, exc_info=True"
|
|
562
|
+
)
|
|
563
|
+
raise
|
|
564
|
+
|
|
565
|
+
def _raise_on_failure(
|
|
566
|
+
self, returncode: int, cmd: list[str], stdout: str, stderr: str
|
|
567
|
+
) -> None:
|
|
568
|
+
"""命令失败时抛出异常.
|
|
569
|
+
|
|
570
|
+
Parameters
|
|
571
|
+
----------
|
|
572
|
+
returncode : int
|
|
573
|
+
命令返回码
|
|
574
|
+
cmd : list[str]
|
|
575
|
+
命令及其参数列表
|
|
576
|
+
stdout : str
|
|
577
|
+
标准输出内容
|
|
578
|
+
stderr : str
|
|
579
|
+
标准错误内容
|
|
580
|
+
|
|
581
|
+
Raises
|
|
582
|
+
------
|
|
583
|
+
subprocess.CalledProcessError
|
|
584
|
+
命令返回非零退出码
|
|
585
|
+
"""
|
|
586
|
+
raise subprocess.CalledProcessError(returncode, cmd, stdout, stderr)
|
|
587
|
+
|
|
588
|
+
def _read_stream(
|
|
589
|
+
self, stream: TextIOBase, output_list: list[str], stream_name: str
|
|
590
|
+
) -> None:
|
|
591
|
+
"""读取流式输出.
|
|
592
|
+
|
|
593
|
+
Parameters
|
|
594
|
+
----------
|
|
595
|
+
stream : file-like object
|
|
596
|
+
输出流
|
|
597
|
+
output_list : list[str]
|
|
598
|
+
存储输出内容的列表
|
|
599
|
+
stream_name : str
|
|
600
|
+
流名称(STDOUT/STDERR)
|
|
601
|
+
text_mode : bool
|
|
602
|
+
是否为文本模式
|
|
603
|
+
"""
|
|
604
|
+
try:
|
|
605
|
+
# 预分配列表容量以提升性能(估计值)
|
|
606
|
+
for line in stream:
|
|
607
|
+
# 处理bytes或str类型
|
|
608
|
+
line_str = (
|
|
609
|
+
line.decode("utf-8", errors="replace")
|
|
610
|
+
if isinstance(line, bytes)
|
|
611
|
+
else line
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
line_str = line_str.rstrip("\n")
|
|
615
|
+
if line_str.strip(): # 只记录非空白行
|
|
616
|
+
output_list.append(line_str)
|
|
617
|
+
# 实时记录输出到INFO级别,确保用户能看到执行进展
|
|
618
|
+
if stream_name == "STDERR":
|
|
619
|
+
logger.info(f"[STDERR] {line_str}")
|
|
620
|
+
else:
|
|
621
|
+
logger.info(f"[STDOUT] {line_str}")
|
|
622
|
+
except (OSError, RuntimeError) as e:
|
|
623
|
+
logger.error(f"读取{stream_name}流时发生错误:{e}")
|
|
624
|
+
except ValueError as e:
|
|
625
|
+
# 处理流关闭时的 ValueError
|
|
626
|
+
logger.debug(f"读取{stream_name}流时发生ValueError, 可能是流已关闭:{e}")
|
|
627
|
+
|
|
628
|
+
def run_batch(
|
|
629
|
+
self,
|
|
630
|
+
commands: Sequence[list[str] | Callable[[], RunResult]],
|
|
631
|
+
max_concurrent: int | None = None,
|
|
632
|
+
timeout: int | None = None,
|
|
633
|
+
) -> list[RunResult]:
|
|
634
|
+
"""批量执行命令.
|
|
635
|
+
|
|
636
|
+
Parameters
|
|
637
|
+
----------
|
|
638
|
+
commands : Sequence[list[str] | Callable[[], RunResult]]
|
|
639
|
+
命令列表或可调用对象列表
|
|
640
|
+
max_concurrent : int, optional
|
|
641
|
+
最大并发数,默认根据 CPU 配置计算
|
|
642
|
+
timeout : int, optional
|
|
643
|
+
每个命令的超时时间
|
|
644
|
+
|
|
645
|
+
Returns
|
|
646
|
+
-------
|
|
647
|
+
list[RunResult]
|
|
648
|
+
命令执行结果列表
|
|
649
|
+
"""
|
|
650
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
651
|
+
|
|
652
|
+
if not commands:
|
|
653
|
+
logger.info("批量执行命令列表为空, 直接返回")
|
|
654
|
+
return []
|
|
655
|
+
|
|
656
|
+
effective_timeout = timeout if timeout is not None else _conf.TIMEOUT
|
|
657
|
+
effective_max_workers = (
|
|
658
|
+
max_concurrent if max_concurrent is not None else _conf._MAX_WORKERS
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
logger.info(
|
|
662
|
+
f"开始批量执行 {len(commands)} 个命令, 最大并发数:{effective_max_workers}, 超时时间:{effective_timeout}秒"
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
results: list[RunResult] = []
|
|
666
|
+
batch_start_time = time.time()
|
|
667
|
+
completed_count = 0
|
|
668
|
+
total_count = len(commands)
|
|
669
|
+
|
|
670
|
+
with ThreadPoolExecutor(max_workers=effective_max_workers) as executor:
|
|
671
|
+
# 提交所有任务
|
|
672
|
+
future_to_cmd = {}
|
|
673
|
+
for idx, cmd in enumerate(commands):
|
|
674
|
+
if callable(cmd):
|
|
675
|
+
# 如果是可调用对象,直接提交
|
|
676
|
+
future = executor.submit(cmd)
|
|
677
|
+
future_to_cmd[future] = (cmd, idx)
|
|
678
|
+
logger.debug(
|
|
679
|
+
f"提交可调用任务 [{idx + 1}/{total_count}]: {cmd.__name__ if hasattr(cmd, '__name__') else 'lambda'}"
|
|
680
|
+
)
|
|
681
|
+
else:
|
|
682
|
+
# 如果是命令列表,调用 self.run
|
|
683
|
+
future = executor.submit(self.run, cmd, timeout=effective_timeout)
|
|
684
|
+
future_to_cmd[future] = (cmd, idx)
|
|
685
|
+
logger.debug(
|
|
686
|
+
f"提交命令任务 [{idx + 1}/{total_count}]: {' '.join(cmd)}"
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
# 收集结果
|
|
690
|
+
for future in as_completed(future_to_cmd):
|
|
691
|
+
cmd, idx = future_to_cmd[future]
|
|
692
|
+
completed_count += 1
|
|
693
|
+
try:
|
|
694
|
+
result = future.result()
|
|
695
|
+
results.append(result)
|
|
696
|
+
|
|
697
|
+
# 记录单个命令执行结果
|
|
698
|
+
cmd_str = (
|
|
699
|
+
" ".join(cmd)
|
|
700
|
+
if isinstance(cmd, list)
|
|
701
|
+
else (cmd.__name__ if hasattr(cmd, "__name__") else str(cmd))
|
|
702
|
+
)
|
|
703
|
+
if result.success:
|
|
704
|
+
logger.info(
|
|
705
|
+
f"[{completed_count}/{total_count}] 成功: {cmd_str} (耗时:{result.execution_time:.2f}秒)"
|
|
706
|
+
)
|
|
707
|
+
# 显示命令输出
|
|
708
|
+
if result.stdout:
|
|
709
|
+
for line in result.stdout.strip().split("\n"):
|
|
710
|
+
if line.strip():
|
|
711
|
+
logger.info(f" 输出: {line.strip()}")
|
|
712
|
+
else:
|
|
713
|
+
# 根据返回码决定日志级别
|
|
714
|
+
if result.returncode < 0:
|
|
715
|
+
logger.error(
|
|
716
|
+
f"[{completed_count}/{total_count}] 失败: {cmd_str} (返回码:{result.returncode})"
|
|
717
|
+
)
|
|
718
|
+
else:
|
|
719
|
+
logger.warning(
|
|
720
|
+
f"[{completed_count}/{total_count}] 失败: {cmd_str} (返回码:{result.returncode})"
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
# 显示错误输出
|
|
724
|
+
if result.stderr:
|
|
725
|
+
for line in result.stderr.strip().split("\n"):
|
|
726
|
+
if line.strip():
|
|
727
|
+
logger.error(f" 错误: {line.strip()}")
|
|
728
|
+
elif result.stdout:
|
|
729
|
+
for line in result.stdout.strip().split("\n"):
|
|
730
|
+
if line.strip():
|
|
731
|
+
logger.warning(f" 输出: {line.strip()}")
|
|
732
|
+
except (OSError, RuntimeError, ValueError) as e:
|
|
733
|
+
cmd_str = " ".join(cmd) if isinstance(cmd, list) else str(cmd)
|
|
734
|
+
logger.error(
|
|
735
|
+
f"[{completed_count}/{total_count}] 异常: {cmd_str}, 错误:{e}"
|
|
736
|
+
)
|
|
737
|
+
# 添加失败结果
|
|
738
|
+
cmd_list = cmd if isinstance(cmd, list) else []
|
|
739
|
+
results.append(
|
|
740
|
+
RunResult(
|
|
741
|
+
returncode=-1,
|
|
742
|
+
stdout="",
|
|
743
|
+
stderr=str(e),
|
|
744
|
+
execution_time=0.0,
|
|
745
|
+
mode="failed",
|
|
746
|
+
cmd=cmd_list,
|
|
747
|
+
)
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
batch_execution_time = time.time() - batch_start_time
|
|
751
|
+
success_count = sum(1 for r in results if r.returncode == 0)
|
|
752
|
+
failed_count = len(results) - success_count
|
|
753
|
+
|
|
754
|
+
logger.info(
|
|
755
|
+
f"批量命令执行完成 | 总数:{total_count} | 成功:{success_count} | 失败:{failed_count} | "
|
|
756
|
+
f"总耗时:{batch_execution_time:.2f}秒"
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
# 如果有失败的命令,输出详细信息
|
|
760
|
+
if failed_count > 0:
|
|
761
|
+
failed_results = [r for r in results if r.returncode != 0]
|
|
762
|
+
logger.warning("失败的命令详情:")
|
|
763
|
+
for r in failed_results:
|
|
764
|
+
cmd_str = " ".join(r.cmd) if r.cmd else "unknown"
|
|
765
|
+
logger.warning(f" - {cmd_str} (返回码:{r.returncode})")
|
|
766
|
+
|
|
767
|
+
return results
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
_executor = ProcessExecutor()
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
# 类型重载: 单个命令模式
|
|
774
|
+
@overload
|
|
775
|
+
def execute(
|
|
776
|
+
cmd: list[str],
|
|
777
|
+
cwd: Path | None = ...,
|
|
778
|
+
env: dict[str, str] | None = ...,
|
|
779
|
+
timeout: int | None = ...,
|
|
780
|
+
*,
|
|
781
|
+
check: bool = ...,
|
|
782
|
+
capture_output: bool = ...,
|
|
783
|
+
text: bool = ...,
|
|
784
|
+
max_concurrent: int | None = ...,
|
|
785
|
+
) -> RunResult: ...
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
# 类型重载: 批量命令模式
|
|
789
|
+
@overload
|
|
790
|
+
def execute(
|
|
791
|
+
cmd: Sequence[list[str] | Callable[[], RunResult]],
|
|
792
|
+
cwd: Path | None = ...,
|
|
793
|
+
env: dict[str, str] | None = ...,
|
|
794
|
+
timeout: int | None = ...,
|
|
795
|
+
*,
|
|
796
|
+
check: bool = ...,
|
|
797
|
+
capture_output: bool = ...,
|
|
798
|
+
text: bool = ...,
|
|
799
|
+
max_concurrent: int | None = ...,
|
|
800
|
+
) -> list[RunResult]: ...
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def execute(
|
|
804
|
+
cmd: list[str] | Sequence[list[str] | Callable[[], RunResult]],
|
|
805
|
+
cwd: Path | None = None,
|
|
806
|
+
env: dict[str, str] | None = None,
|
|
807
|
+
timeout: int | None = None,
|
|
808
|
+
*,
|
|
809
|
+
check: bool = False,
|
|
810
|
+
capture_output: bool = True,
|
|
811
|
+
text: bool = True,
|
|
812
|
+
max_concurrent: int | None = None,
|
|
813
|
+
) -> RunResult | list[RunResult]:
|
|
814
|
+
"""执行命令或批量执行命令.
|
|
815
|
+
|
|
816
|
+
智能判断输入类型:
|
|
817
|
+
- 如果 cmd 是 list[str],执行单个命令
|
|
818
|
+
- 如果 cmd 是 Sequence[list[str] | Callable],批量执行命令
|
|
819
|
+
|
|
820
|
+
Parameters
|
|
821
|
+
----------
|
|
822
|
+
cmd : list[str] | Sequence[list[str] | Callable[[], RunResult]]
|
|
823
|
+
单个命令列表或命令列表序列(支持可调用对象)
|
|
824
|
+
cwd : Path, optional
|
|
825
|
+
工作目录(仅单个命令模式有效)
|
|
826
|
+
env : dict[str, str], optional
|
|
827
|
+
环境变量(仅单个命令模式有效)
|
|
828
|
+
timeout : int, optional
|
|
829
|
+
超时时间(秒),默认使用实例配置
|
|
830
|
+
check : bool, optional
|
|
831
|
+
是否检查返回码,非零时抛出异常(仅单个命令模式有效)
|
|
832
|
+
capture_output : bool, optional
|
|
833
|
+
是否捕获输出(仅单个命令模式有效)
|
|
834
|
+
text : bool, optional
|
|
835
|
+
是否以文本模式处理输出(仅单个命令模式有效)
|
|
836
|
+
max_concurrent : int, optional
|
|
837
|
+
最大并发数(仅批量命令模式有效)
|
|
838
|
+
|
|
839
|
+
Returns
|
|
840
|
+
-------
|
|
841
|
+
RunResult | list[RunResult]
|
|
842
|
+
单个命令返回 RunResult,批量命令返回 list[RunResult]
|
|
843
|
+
|
|
844
|
+
Examples
|
|
845
|
+
--------
|
|
846
|
+
>>> # 执行单个命令
|
|
847
|
+
>>> result = execute(["ls", "-la"])
|
|
848
|
+
>>> print(result.returncode)
|
|
849
|
+
|
|
850
|
+
>>> # 批量执行命令
|
|
851
|
+
>>> results = execute([
|
|
852
|
+
... ["echo", "hello"],
|
|
853
|
+
... ["echo", "world"],
|
|
854
|
+
... ])
|
|
855
|
+
>>> for r in results:
|
|
856
|
+
... print(r.returncode)
|
|
857
|
+
"""
|
|
858
|
+
# 判断是否为批量命令模式:检查第一个元素是否为 list 或 Callable
|
|
859
|
+
# 注意:单个命令如 ["echo", "hello"] 的第一个元素是字符串,不是 list
|
|
860
|
+
if cmd and isinstance(cmd[0], (list, Callable)):
|
|
861
|
+
# 批量命令模式:通过重载推断类型
|
|
862
|
+
return _executor.run_batch(
|
|
863
|
+
cmd, # ty: ignore[invalid-argument-type]
|
|
864
|
+
max_concurrent=max_concurrent,
|
|
865
|
+
timeout=timeout,
|
|
866
|
+
)
|
|
867
|
+
else:
|
|
868
|
+
# 单个命令模式:通过 isinstance 守卫确保类型安全
|
|
869
|
+
if not isinstance(cmd, list):
|
|
870
|
+
msg = f"命令列表应为 list[str],实际为 {type(cmd).__name__}"
|
|
871
|
+
raise TypeError(msg)
|
|
872
|
+
# 单个命令:直接传递,通过类型重载匹配第一个签名
|
|
873
|
+
return _executor.run(
|
|
874
|
+
cmd, # ty: ignore[invalid-argument-type]
|
|
875
|
+
cwd=cwd,
|
|
876
|
+
env=env,
|
|
877
|
+
timeout=timeout,
|
|
878
|
+
check=check,
|
|
879
|
+
capture_output=capture_output,
|
|
880
|
+
text=text,
|
|
881
|
+
)
|