autoglm-gui 0.4.8__py3-none-any.whl → 0.4.11__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.
AutoGLM_GUI/api/agents.py CHANGED
@@ -1,16 +1,25 @@
1
1
  """Agent lifecycle and chat routes."""
2
2
 
3
3
  import json
4
+ import os
4
5
 
5
6
  from fastapi import APIRouter, HTTPException
6
7
  from fastapi.responses import StreamingResponse
7
8
 
8
9
  from AutoGLM_GUI.config import config
10
+ from AutoGLM_GUI.config_manager import (
11
+ delete_config_file,
12
+ get_config_path,
13
+ load_config_file,
14
+ save_config_file,
15
+ )
9
16
  from AutoGLM_GUI.schemas import (
10
17
  APIAgentConfig,
11
18
  APIModelConfig,
12
19
  ChatRequest,
13
20
  ChatResponse,
21
+ ConfigResponse,
22
+ ConfigSaveRequest,
14
23
  InitRequest,
15
24
  ResetRequest,
16
25
  StatusResponse,
@@ -31,6 +40,9 @@ router = APIRouter()
31
40
  @router.post("/api/init")
32
41
  def init_agent(request: InitRequest) -> dict:
33
42
  """初始化 PhoneAgent(多设备支持)。"""
43
+ from AutoGLM_GUI.adb_plus import ADBKeyboardInstaller
44
+ from AutoGLM_GUI.logger import logger
45
+
34
46
  req_model_config = request.model or APIModelConfig()
35
47
  req_agent_config = request.agent or APIAgentConfig()
36
48
 
@@ -41,13 +53,29 @@ def init_agent(request: InitRequest) -> dict:
41
53
  )
42
54
  config.refresh_from_env()
43
55
 
56
+ # 检查并自动安装 ADB Keyboard
57
+ logger.info(f"Checking ADB Keyboard for device {device_id}...")
58
+ installer = ADBKeyboardInstaller(device_id=device_id)
59
+ status = installer.get_status()
60
+
61
+ if not (status["installed"] and status["enabled"]):
62
+ logger.info(f"Setting up ADB Keyboard for device {device_id}...")
63
+ success, message = installer.auto_setup()
64
+ if success:
65
+ logger.info(f"✓ Device {device_id}: {message}")
66
+ else:
67
+ logger.warning(f"✗ Device {device_id}: {message}")
68
+ else:
69
+ logger.info(f"✓ Device {device_id}: ADB Keyboard ready")
70
+
44
71
  base_url = req_model_config.base_url or config.base_url
45
72
  api_key = req_model_config.api_key or config.api_key
46
73
  model_name = req_model_config.model_name or config.model_name
47
74
 
48
75
  if not base_url:
49
76
  raise HTTPException(
50
- status_code=400, detail="base_url is required (in model_config or env)"
77
+ status_code=400,
78
+ detail="base_url is required. Please configure via Settings or start with --base-url",
51
79
  )
52
80
 
53
81
  model_config = ModelConfig(
@@ -228,3 +256,97 @@ def reset_agent(request: ResetRequest) -> dict:
228
256
  "device_id": device_id,
229
257
  "message": f"Agent reset for device {device_id}",
230
258
  }
259
+
260
+
261
+ @router.get("/api/config", response_model=ConfigResponse)
262
+ def get_config_endpoint() -> ConfigResponse:
263
+ """获取当前有效配置."""
264
+ from AutoGLM_GUI.config import config
265
+
266
+ # 加载配置文件
267
+ file_config = load_config_file()
268
+
269
+ # 读取当前实际运行的配置
270
+ current_base_url = os.getenv("AUTOGLM_BASE_URL", config.base_url)
271
+ current_model_name = os.getenv("AUTOGLM_MODEL_NAME", config.model_name)
272
+ current_api_key = os.getenv("AUTOGLM_API_KEY", config.api_key)
273
+
274
+ # 判断配置来源
275
+ # 如果环境变量中有 CLI 参数设置的值,优先级最高
276
+ env_config = {
277
+ "base_url": os.getenv("AUTOGLM_BASE_URL"),
278
+ "model_name": os.getenv("AUTOGLM_MODEL_NAME"),
279
+ "api_key": os.getenv("AUTOGLM_API_KEY"),
280
+ }
281
+
282
+ # 检查是否有 CLI 参数(环境变量值不同于默认值且文件配置中没有对应值)
283
+ has_cli_config = (
284
+ (env_config["base_url"] and env_config["base_url"] != "") and
285
+ (not file_config or file_config.get("base_url") != env_config["base_url"])
286
+ ) or (
287
+ (env_config["model_name"] and env_config["model_name"] != "autoglm-phone-9b") and
288
+ (not file_config or file_config.get("model_name") != env_config["model_name"])
289
+ ) or (
290
+ (env_config["api_key"] and env_config["api_key"] != "EMPTY") and
291
+ (not file_config or file_config.get("api_key") != env_config["api_key"])
292
+ )
293
+
294
+ if has_cli_config:
295
+ source = "CLI arguments"
296
+ elif file_config:
297
+ source = "config file"
298
+ else:
299
+ source = "default"
300
+
301
+ return ConfigResponse(
302
+ base_url=current_base_url,
303
+ model_name=current_model_name,
304
+ api_key=current_api_key if current_api_key != "EMPTY" else "",
305
+ source=source,
306
+ )
307
+
308
+
309
+ @router.post("/api/config")
310
+ def save_config_endpoint(request: ConfigSaveRequest) -> dict:
311
+ """保存配置到文件."""
312
+ try:
313
+ config_data = {
314
+ "base_url": request.base_url,
315
+ "model_name": request.model_name,
316
+ }
317
+
318
+ # 只有提供了 api_key 才保存
319
+ if request.api_key:
320
+ config_data["api_key"] = request.api_key
321
+
322
+ success = save_config_file(config_data)
323
+
324
+ if success:
325
+ # 刷新全局配置
326
+ os.environ["AUTOGLM_BASE_URL"] = request.base_url
327
+ os.environ["AUTOGLM_MODEL_NAME"] = request.model_name
328
+ if request.api_key:
329
+ os.environ["AUTOGLM_API_KEY"] = request.api_key
330
+ config.refresh_from_env()
331
+
332
+ return {
333
+ "success": True,
334
+ "message": f"Configuration saved to {get_config_path()}",
335
+ }
336
+ else:
337
+ raise HTTPException(status_code=500, detail="Failed to save config")
338
+ except Exception as e:
339
+ raise HTTPException(status_code=500, detail=str(e))
340
+
341
+
342
+ @router.delete("/api/config")
343
+ def delete_config_endpoint() -> dict:
344
+ """删除配置文件."""
345
+ try:
346
+ success = delete_config_file()
347
+ if success:
348
+ return {"success": True, "message": "Configuration deleted"}
349
+ else:
350
+ raise HTTPException(status_code=500, detail="Failed to delete config")
351
+ except Exception as e:
352
+ raise HTTPException(status_code=500, detail=str(e))
AutoGLM_GUI/api/media.py CHANGED
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
  from fastapi import APIRouter, WebSocket, WebSocketDisconnect
8
8
 
9
9
  from AutoGLM_GUI.adb_plus import capture_screenshot
10
+ from AutoGLM_GUI.logger import logger
10
11
  from AutoGLM_GUI.schemas import ScreenshotRequest, ScreenshotResponse
11
12
  from AutoGLM_GUI.scrcpy_stream import ScrcpyStreamer
12
13
  from AutoGLM_GUI.state import scrcpy_locks, scrcpy_streamers
@@ -24,10 +25,10 @@ async def reset_video_stream(device_id: str | None = None) -> dict:
24
25
  if device_id in scrcpy_locks:
25
26
  async with scrcpy_locks[device_id]:
26
27
  if device_id in scrcpy_streamers:
27
- print(f"[video/reset] Stopping streamer for device {device_id}")
28
+ logger.info(f"Stopping streamer for device {device_id}")
28
29
  scrcpy_streamers[device_id].stop()
29
30
  del scrcpy_streamers[device_id]
30
- print(f"[video/reset] Streamer reset for device {device_id}")
31
+ logger.info(f"Streamer reset for device {device_id}")
31
32
  return {
32
33
  "success": True,
33
34
  "message": f"Video stream reset for device {device_id}",
@@ -45,7 +46,7 @@ async def reset_video_stream(device_id: str | None = None) -> dict:
45
46
  if dev_id in scrcpy_streamers:
46
47
  scrcpy_streamers[dev_id].stop()
47
48
  del scrcpy_streamers[dev_id]
48
- print("[video/reset] All streamers reset")
49
+ logger.info("All streamers reset")
49
50
  return {"success": True, "message": "All video streams reset"}
50
51
 
51
52
 
@@ -84,43 +85,45 @@ async def video_stream_ws(
84
85
  await websocket.send_json({"error": "device_id is required"})
85
86
  return
86
87
 
87
- print(f"[video/stream] WebSocket connection for device {device_id}")
88
+ logger.info(f"WebSocket connection for device {device_id}")
88
89
 
89
90
  # Debug: Save stream to file for analysis (controlled by DEBUG_SAVE_VIDEO_STREAM env var)
90
91
  debug_file = None
91
92
  if DEBUG_SAVE_STREAM:
92
93
  debug_dir = Path("debug_streams")
93
94
  debug_dir.mkdir(exist_ok=True)
94
- debug_file_path = debug_dir / f"{device_id}_{int(__import__('time').time())}.h264"
95
+ debug_file_path = (
96
+ debug_dir / f"{device_id}_{int(__import__('time').time())}.h264"
97
+ )
95
98
  debug_file = open(debug_file_path, "wb")
96
- print(f"[video/stream] DEBUG: Saving stream to {debug_file_path}")
99
+ logger.debug(f"DEBUG: Saving stream to {debug_file_path}")
97
100
 
98
101
  if device_id not in scrcpy_locks:
99
102
  scrcpy_locks[device_id] = asyncio.Lock()
100
103
 
101
104
  async with scrcpy_locks[device_id]:
102
105
  if device_id not in scrcpy_streamers:
103
- print(f"[video/stream] Creating streamer for device {device_id}")
106
+ logger.info(f"Creating streamer for device {device_id}")
104
107
  scrcpy_streamers[device_id] = ScrcpyStreamer(
105
108
  device_id=device_id, max_size=1280, bit_rate=4_000_000
106
109
  )
107
110
 
108
111
  try:
109
- print(f"[video/stream] Starting scrcpy server for device {device_id}")
112
+ logger.info(f"Starting scrcpy server for device {device_id}")
110
113
  await scrcpy_streamers[device_id].start()
111
- print(f"[video/stream] Scrcpy server started for device {device_id}")
114
+ logger.info(f"Scrcpy server started for device {device_id}")
112
115
 
113
116
  # Read NAL units until we have SPS, PPS, and IDR
114
117
  streamer = scrcpy_streamers[device_id]
115
118
 
116
- print("[video/stream] Reading NAL units for initialization...")
119
+ logger.debug("Reading NAL units for initialization...")
117
120
  for attempt in range(20): # Max 20 NAL units for initialization
118
121
  try:
119
122
  nal_unit = await streamer.read_nal_unit(auto_cache=True)
120
123
  nal_type = nal_unit[4] & 0x1F if len(nal_unit) > 4 else -1
121
124
  nal_type_names = {5: "IDR", 7: "SPS", 8: "PPS"}
122
- print(
123
- f"[video/stream] Read NAL unit: type={nal_type_names.get(nal_type, nal_type)}, size={len(nal_unit)} bytes"
125
+ logger.debug(
126
+ f"Read NAL unit: type={nal_type_names.get(nal_type, nal_type)}, size={len(nal_unit)} bytes"
124
127
  )
125
128
 
126
129
  # Check if we have all required parameter sets
@@ -129,12 +132,12 @@ async def video_stream_ws(
129
132
  and streamer.cached_pps
130
133
  and streamer.cached_idr
131
134
  ):
132
- print(
133
- f"[video/stream] ✓ Initialization complete: SPS={len(streamer.cached_sps)}B, PPS={len(streamer.cached_pps)}B, IDR={len(streamer.cached_idr)}B"
135
+ logger.debug(
136
+ f"✓ Initialization complete: SPS={len(streamer.cached_sps)}B, PPS={len(streamer.cached_pps)}B, IDR={len(streamer.cached_idr)}B"
134
137
  )
135
138
  break
136
139
  except Exception as e:
137
- print(f"[video/stream] Failed to read NAL unit: {e}")
140
+ logger.warning(f"Failed to read NAL unit: {e}")
138
141
  await asyncio.sleep(0.5)
139
142
  continue
140
143
 
@@ -147,8 +150,8 @@ async def video_stream_ws(
147
150
 
148
151
  # Send initialization data as ONE message (SPS+PPS+IDR combined)
149
152
  await websocket.send_bytes(init_data)
150
- print(
151
- f"[video/stream] ✓ Sent initialization data to first client: {len(init_data)} bytes total"
153
+ logger.debug(
154
+ f"✓ Sent initialization data to first client: {len(init_data)} bytes total"
152
155
  )
153
156
 
154
157
  # Debug: Save to file
@@ -157,10 +160,7 @@ async def video_stream_ws(
157
160
  debug_file.flush()
158
161
 
159
162
  except Exception as e:
160
- import traceback
161
-
162
- print(f"[video/stream] Failed to start streamer: {e}")
163
- print(f"[video/stream] Traceback:\n{traceback.format_exc()}")
163
+ logger.exception(f"Failed to start streamer: {e}")
164
164
  scrcpy_streamers[device_id].stop()
165
165
  del scrcpy_streamers[device_id]
166
166
  try:
@@ -169,7 +169,7 @@ async def video_stream_ws(
169
169
  pass
170
170
  return
171
171
  else:
172
- print(f"[video/stream] Reusing streamer for device {device_id}")
172
+ logger.info(f"Reusing streamer for device {device_id}")
173
173
 
174
174
  streamer = scrcpy_streamers[device_id]
175
175
  # CRITICAL: Send complete initialization data (SPS+PPS+IDR)
@@ -181,29 +181,29 @@ async def video_stream_ws(
181
181
  init_data = streamer.get_initialization_data()
182
182
  if init_data:
183
183
  break
184
- print(
185
- f"[video/stream] Waiting for initialization data (attempt {attempt + 1}/10)..."
184
+ logger.debug(
185
+ f"Waiting for initialization data (attempt {attempt + 1}/10)..."
186
186
  )
187
187
  await asyncio.sleep(0.5)
188
188
 
189
189
  if init_data:
190
190
  # Log what we're sending
191
- print(
192
- f"[video/stream] ✓ Sending cached initialization data for device {device_id}:"
191
+ logger.debug(
192
+ f"✓ Sending cached initialization data for device {device_id}:"
193
193
  )
194
- print(
194
+ logger.debug(
195
195
  f" - SPS: {len(streamer.cached_sps) if streamer.cached_sps else 0}B"
196
196
  )
197
- print(
197
+ logger.debug(
198
198
  f" - PPS: {len(streamer.cached_pps) if streamer.cached_pps else 0}B"
199
199
  )
200
- print(
200
+ logger.debug(
201
201
  f" - IDR: {len(streamer.cached_idr) if streamer.cached_idr else 0}B"
202
202
  )
203
- print(f" - Total: {len(init_data)} bytes")
203
+ logger.debug(f" - Total: {len(init_data)} bytes")
204
204
 
205
205
  await websocket.send_bytes(init_data)
206
- print("[video/stream] ✓ Initialization data sent successfully")
206
+ logger.debug("✓ Initialization data sent successfully")
207
207
 
208
208
  # Debug: Save to file
209
209
  if debug_file:
@@ -211,7 +211,7 @@ async def video_stream_ws(
211
211
  debug_file.flush()
212
212
  else:
213
213
  error_msg = f"Initialization data not ready for device {device_id} after 5 seconds"
214
- print(f"[video/stream] ERROR: {error_msg}")
214
+ logger.error(f"ERROR: {error_msg}")
215
215
  try:
216
216
  await websocket.send_json({"error": error_msg})
217
217
  except Exception:
@@ -237,11 +237,9 @@ async def video_stream_ws(
237
237
 
238
238
  nal_count += 1
239
239
  if nal_count % 100 == 0:
240
- print(
241
- f"[video/stream] Device {device_id}: Sent {nal_count} NAL units"
242
- )
240
+ logger.debug(f"Device {device_id}: Sent {nal_count} NAL units")
243
241
  except ConnectionError as e:
244
- print(f"[video/stream] Device {device_id}: Connection error: {e}")
242
+ logger.warning(f"Device {device_id}: Connection error: {e}")
245
243
  stream_failed = True
246
244
  try:
247
245
  await websocket.send_json({"error": f"Stream error: {str(e)}"})
@@ -250,12 +248,9 @@ async def video_stream_ws(
250
248
  break
251
249
 
252
250
  except WebSocketDisconnect:
253
- print(f"[video/stream] Device {device_id}: Client disconnected")
251
+ logger.info(f"Device {device_id}: Client disconnected")
254
252
  except Exception as e:
255
- import traceback
256
-
257
- print(f"[video/stream] Device {device_id}: Error: {e}")
258
- print(f"[video/stream] Traceback:\n{traceback.format_exc()}")
253
+ logger.exception(f"Device {device_id}: Error: {e}")
259
254
  stream_failed = True
260
255
  try:
261
256
  await websocket.send_json({"error": str(e)})
@@ -265,13 +260,13 @@ async def video_stream_ws(
265
260
  if stream_failed:
266
261
  async with scrcpy_locks[device_id]:
267
262
  if device_id in scrcpy_streamers:
268
- print(f"[video/stream] Resetting streamer for device {device_id}")
263
+ logger.info(f"Resetting streamer for device {device_id}")
269
264
  scrcpy_streamers[device_id].stop()
270
265
  del scrcpy_streamers[device_id]
271
266
 
272
267
  # Debug: Close file
273
268
  if debug_file:
274
269
  debug_file.close()
275
- print("[video/stream] DEBUG: Closed debug file")
270
+ logger.debug("DEBUG: Closed debug file")
276
271
 
277
- print(f"[video/stream] Device {device_id}: Stream ended")
272
+ logger.info(f"Device {device_id}: Stream ended")
@@ -0,0 +1,124 @@
1
+ """配置文件管理模块."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from AutoGLM_GUI.logger import logger
7
+
8
+ # 默认配置
9
+ DEFAULT_CONFIG = {"base_url": "", "model_name": "autoglm-phone-9b", "api_key": "EMPTY"}
10
+
11
+
12
+ def get_config_path() -> Path:
13
+ """获取配置文件路径.
14
+
15
+ Returns:
16
+ Path: 配置文件路径 (~/.config/autoglm/config.json)
17
+ """
18
+ config_dir = Path.home() / ".config" / "autoglm"
19
+ return config_dir / "config.json"
20
+
21
+
22
+ def load_config_file() -> dict | None:
23
+ """从文件加载配置.
24
+
25
+ Returns:
26
+ dict | None: 配置字典,如果文件不存在或加载失败则返回 None
27
+ """
28
+ config_path = get_config_path()
29
+
30
+ # 文件不存在(首次运行)
31
+ if not config_path.exists():
32
+ logger.debug(f"Config file not found at {config_path}")
33
+ return None
34
+
35
+ try:
36
+ with open(config_path, "r", encoding="utf-8") as f:
37
+ config = json.load(f)
38
+ logger.info(f"Loaded configuration from {config_path}")
39
+ return config
40
+ except json.JSONDecodeError as e:
41
+ logger.warning(f"Failed to parse config file {config_path}: {e}")
42
+ return None
43
+ except Exception as e:
44
+ logger.error(f"Failed to read config file {config_path}: {e}")
45
+ return None
46
+
47
+
48
+ def save_config_file(config: dict) -> bool:
49
+ """保存配置到文件(原子写入).
50
+
51
+ Args:
52
+ config: 配置字典
53
+
54
+ Returns:
55
+ bool: 成功返回 True,失败返回 False
56
+ """
57
+ config_path = get_config_path()
58
+
59
+ try:
60
+ # 确保目录存在
61
+ config_path.parent.mkdir(parents=True, exist_ok=True)
62
+
63
+ # 原子写入:先写入临时文件,然后重命名
64
+ temp_path = config_path.with_suffix(".tmp")
65
+ with open(temp_path, "w", encoding="utf-8") as f:
66
+ json.dump(config, f, indent=2, ensure_ascii=False)
67
+
68
+ # 重命名(原子操作)
69
+ temp_path.replace(config_path)
70
+
71
+ logger.info(f"Configuration saved to {config_path}")
72
+ return True
73
+ except Exception as e:
74
+ logger.error(f"Failed to save config file {config_path}: {e}")
75
+ return False
76
+
77
+
78
+ def delete_config_file() -> bool:
79
+ """删除配置文件.
80
+
81
+ Returns:
82
+ bool: 成功返回 True,失败返回 False
83
+ """
84
+ config_path = get_config_path()
85
+
86
+ if not config_path.exists():
87
+ logger.debug(f"Config file does not exist: {config_path}")
88
+ return True
89
+
90
+ try:
91
+ config_path.unlink()
92
+ logger.info(f"Configuration deleted: {config_path}")
93
+ return True
94
+ except Exception as e:
95
+ logger.error(f"Failed to delete config file {config_path}: {e}")
96
+ return False
97
+
98
+
99
+ def merge_configs(file_config: dict | None, cli_config: dict | None) -> dict:
100
+ """合并配置(优先级:CLI > 文件 > 默认值).
101
+
102
+ Args:
103
+ file_config: 从文件加载的配置(可为 None)
104
+ cli_config: CLI 参数配置(可为 None)
105
+
106
+ Returns:
107
+ dict: 合并后的配置字典
108
+ """
109
+ # 从默认配置开始
110
+ merged = DEFAULT_CONFIG.copy()
111
+
112
+ # 应用文件配置(如果存在)
113
+ if file_config:
114
+ for key in DEFAULT_CONFIG.keys():
115
+ if key in file_config:
116
+ merged[key] = file_config[key]
117
+
118
+ # 应用 CLI 配置(最高优先级)
119
+ if cli_config:
120
+ for key in DEFAULT_CONFIG.keys():
121
+ if key in cli_config:
122
+ merged[key] = cli_config[key]
123
+
124
+ return merged
AutoGLM_GUI/logger.py ADDED
@@ -0,0 +1,85 @@
1
+ """
2
+ Centralized logging configuration using loguru.
3
+ """
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from loguru import logger
8
+
9
+ # Remove default handler
10
+ logger.remove()
11
+
12
+ # Default configuration - will be overridden by configure_logger()
13
+ _configured = False
14
+
15
+
16
+ def configure_logger(
17
+ console_level: str = "INFO",
18
+ log_file: str | None = "logs/autoglm_{time:YYYY-MM-DD}.log",
19
+ log_level: str = "DEBUG",
20
+ rotation: str = "100 MB",
21
+ retention: str = "7 days",
22
+ compression: str = "zip",
23
+ ) -> None:
24
+ """
25
+ Configure the global logger with console and file handlers.
26
+
27
+ Args:
28
+ console_level: Console output level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
29
+ log_file: Log file path (None to disable file logging)
30
+ log_level: File logging level
31
+ rotation: Log rotation policy (e.g., "100 MB", "1 day")
32
+ retention: Log retention policy (e.g., "7 days", "1 week")
33
+ compression: Compression format for rotated logs (e.g., "zip", "gz")
34
+ """
35
+ global _configured
36
+
37
+ # Remove existing handlers if reconfiguring
38
+ if _configured:
39
+ logger.remove()
40
+
41
+ # Console handler with colors
42
+ logger.add(
43
+ sys.stderr,
44
+ format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
45
+ level=console_level,
46
+ colorize=True,
47
+ )
48
+
49
+ # File handler
50
+ if log_file:
51
+ # Create logs directory if it doesn't exist
52
+ log_path = Path(log_file)
53
+ log_path.parent.mkdir(parents=True, exist_ok=True)
54
+
55
+ logger.add(
56
+ log_file,
57
+ rotation=rotation,
58
+ retention=retention,
59
+ compression=compression,
60
+ level=log_level,
61
+ format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}",
62
+ encoding="utf-8",
63
+ )
64
+
65
+ # Separate error log file
66
+ error_file = str(log_path.parent / f"errors_{log_path.name.split('_', 1)[1]}")
67
+ logger.add(
68
+ error_file,
69
+ rotation="50 MB",
70
+ retention="30 days",
71
+ compression=compression,
72
+ level="ERROR",
73
+ format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}",
74
+ backtrace=True,
75
+ diagnose=True,
76
+ encoding="utf-8",
77
+ )
78
+
79
+ _configured = True
80
+
81
+
82
+ # Default initialization (can be reconfigured later)
83
+ configure_logger()
84
+
85
+ __all__ = ["logger", "configure_logger"]
@@ -0,0 +1,43 @@
1
+ """Platform-aware subprocess helpers to avoid duplicated Windows branches."""
2
+
3
+ import asyncio
4
+ import platform
5
+ import subprocess
6
+ from typing import Any, Sequence
7
+
8
+
9
+ def is_windows() -> bool:
10
+ """Return True if running on Windows."""
11
+ return platform.system() == "Windows"
12
+
13
+
14
+ async def run_cmd_silently(cmd: Sequence[str]) -> subprocess.CompletedProcess:
15
+ """Run a command, suppressing output but preserving it in the result; safe for async contexts on all platforms."""
16
+ if is_windows():
17
+ # Avoid blocking the event loop with a blocking subprocess call on Windows.
18
+ return await asyncio.to_thread(
19
+ subprocess.run, cmd, capture_output=True, text=True, check=False
20
+ )
21
+
22
+ # Use PIPE on macOS/Linux to capture output
23
+ process = await asyncio.create_subprocess_exec(
24
+ *cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
25
+ )
26
+ stdout, stderr = await process.communicate()
27
+ # Decode bytes to string for API consistency across platforms
28
+ stdout_str = stdout.decode("utf-8") if stdout else ""
29
+ stderr_str = stderr.decode("utf-8") if stderr else ""
30
+ # Return CompletedProcess with stdout/stderr for API consistency across platforms
31
+ return_code = process.returncode if process.returncode is not None else -1
32
+ return subprocess.CompletedProcess(cmd, return_code, stdout_str, stderr_str)
33
+
34
+
35
+ async def spawn_process(cmd: Sequence[str], *, capture_output: bool = False) -> Any:
36
+ """Start a long-running process with optional stdio capture."""
37
+ stdout = subprocess.PIPE if capture_output else None
38
+ stderr = subprocess.PIPE if capture_output else None
39
+
40
+ if is_windows():
41
+ return subprocess.Popen(cmd, stdout=stdout, stderr=stderr)
42
+
43
+ return await asyncio.create_subprocess_exec(*cmd, stdout=stdout, stderr=stderr)