ot-cli 0.2.1__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.
Files changed (91) hide show
  1. openterminal/__init__.py +3 -0
  2. openterminal/agent/__init__.py +39 -0
  3. openterminal/agent/backend.py +180 -0
  4. openterminal/agent/build.py +115 -0
  5. openterminal/agent/middleware.py +111 -0
  6. openterminal/agent/model.py +41 -0
  7. openterminal/agent/prompt.py +63 -0
  8. openterminal/agent/runner.py +340 -0
  9. openterminal/agent/sysprobe.py +164 -0
  10. openterminal/agent/token_est.py +49 -0
  11. openterminal/app.py +52 -0
  12. openterminal/approval.py +7 -0
  13. openterminal/backend.py +3 -0
  14. openterminal/banner.py +7 -0
  15. openterminal/cli.py +426 -0
  16. openterminal/cmdset.py +7 -0
  17. openterminal/config.py +322 -0
  18. openterminal/connections/__init__.py +20 -0
  19. openterminal/connections/db.py +240 -0
  20. openterminal/connections/models.py +289 -0
  21. openterminal/connections/secrets.py +68 -0
  22. openterminal/connections_db.py +6 -0
  23. openterminal/core/__init__.py +41 -0
  24. openterminal/core/ai_flow.py +781 -0
  25. openterminal/core/auth.py +499 -0
  26. openterminal/core/cmdset_runner.py +352 -0
  27. openterminal/core/exec_events.py +650 -0
  28. openterminal/core/history.py +79 -0
  29. openterminal/core/hook_gate.py +415 -0
  30. openterminal/core/messages.py +193 -0
  31. openterminal/core/nested_ssh.py +178 -0
  32. openterminal/core/pipeline.py +1187 -0
  33. openterminal/core/pump.py +190 -0
  34. openterminal/core/transcript.py +26 -0
  35. openterminal/frontend/__init__.py +21 -0
  36. openterminal/frontend/approval.py +40 -0
  37. openterminal/frontend/banner.py +40 -0
  38. openterminal/frontend/headless.py +290 -0
  39. openterminal/frontend/render.py +41 -0
  40. openterminal/frontend/taskview.py +184 -0
  41. openterminal/frontend/term.py +1417 -0
  42. openterminal/headless.py +7 -0
  43. openterminal/history_db.py +7 -0
  44. openterminal/local_pty.py +3 -0
  45. openterminal/local_win.py +3 -0
  46. openterminal/policy.py +362 -0
  47. openterminal/render.py +7 -0
  48. openterminal/secrets_store.py +5 -0
  49. openterminal/session/__init__.py +17 -0
  50. openterminal/session/base.py +328 -0
  51. openterminal/session/local_posix.py +184 -0
  52. openterminal/session/local_win.py +173 -0
  53. openterminal/session/ssh.py +321 -0
  54. openterminal/shell/__init__.py +17 -0
  55. openterminal/shell/cmdset.py +49 -0
  56. openterminal/shell/integration.py +1298 -0
  57. openterminal/shell_integration.py +7 -0
  58. openterminal/shell_session.py +8 -0
  59. openterminal/skills/__init__.py +45 -0
  60. openterminal/skills/markdown-tables/SKILL.md +247 -0
  61. openterminal/ssh_pty.py +6 -0
  62. openterminal/sysprobe.py +7 -0
  63. openterminal/taskview.py +7 -0
  64. openterminal/term_frontend.py +7 -0
  65. openterminal/token_est.py +3 -0
  66. openterminal/transcript.py +7 -0
  67. openterminal/web/__init__.py +1 -0
  68. openterminal/web/files_api.py +789 -0
  69. openterminal/web/frontend/index.html +37 -0
  70. openterminal/web/frontend/static/app.js +3632 -0
  71. openterminal/web/frontend/static/block_stream.js +274 -0
  72. openterminal/web/frontend/static/block_view.js +383 -0
  73. openterminal/web/frontend/static/files_panel.js +782 -0
  74. openterminal/web/frontend/static/md_lite.js +127 -0
  75. openterminal/web/frontend/static/modal.js +122 -0
  76. openterminal/web/frontend/static/pad_ledger.js +46 -0
  77. openterminal/web/frontend/static/style.css +915 -0
  78. openterminal/web/frontend/static/ui/ui.css +1 -0
  79. openterminal/web/frontend/static/ui/ui.js +6292 -0
  80. openterminal/web/frontend/vendor/addon-fit.js +2 -0
  81. openterminal/web/frontend/vendor/xterm.css +8 -0
  82. openterminal/web/frontend/vendor/xterm.js +8 -0
  83. openterminal/web/protocol.py +41 -0
  84. openterminal/web/server.py +322 -0
  85. openterminal/web/worker.py +62 -0
  86. ot_cli-0.2.1.dist-info/METADATA +439 -0
  87. ot_cli-0.2.1.dist-info/RECORD +91 -0
  88. ot_cli-0.2.1.dist-info/WHEEL +5 -0
  89. ot_cli-0.2.1.dist-info/entry_points.txt +3 -0
  90. ot_cli-0.2.1.dist-info/licenses/LICENSE +202 -0
  91. ot_cli-0.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,41 @@
1
+ """WebSocket 消息协议:dataclass + 序列化(纯函数,可单测)。
2
+
3
+ 单管线契约(设计文档 §5):
4
+ - 客户端 → 服务端:JSON 文本(控制)或二进制帧(键盘字节直发)。
5
+ - 服务端 → 客户端:JSON 文本(控制/卡片事件)或二进制帧(PTY 字节直写主
6
+ xterm,唯一显示管线)。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+
13
+ from ..core import ClientMsg, ServerMsg # noqa: F401 —— 再导出(旧 import 路径继续可用)
14
+
15
+
16
+ def parse_client(frame: bytes | str) -> ClientMsg:
17
+ """客户端帧 → ClientMsg。二进制帧视为 raw 键盘字节(直发 PTY)。"""
18
+ if isinstance(frame, (bytes, bytearray)):
19
+ return ClientMsg(type="raw", data=bytes(frame))
20
+ obj = json.loads(frame)
21
+ return ClientMsg(
22
+ type=obj.get("type", ""),
23
+ text=obj.get("text", ""),
24
+ decision=obj.get("decision"),
25
+ auth_kind=obj.get("auth_kind", ""),
26
+ remember=bool(obj.get("remember", False)),
27
+ model=obj.get("model", ""),
28
+ cols=obj.get("cols", 0),
29
+ rows=obj.get("rows", 0),
30
+ pad=obj.get("pad", 0),
31
+ mirror=obj.get("mirror"),
32
+ dirty=bool(obj.get("dirty", False)),
33
+ accept=bool(obj.get("accept", False)),
34
+ )
35
+
36
+
37
+ def encode_server(msg: ServerMsg) -> str:
38
+ """序列化 ServerMsg;None/空串/0 字段一并省略,消息保持紧凑。"""
39
+ return json.dumps(
40
+ {k: v for k, v in msg.__dict__.items() if v not in (None, "", 0)},
41
+ ensure_ascii=False)
@@ -0,0 +1,322 @@
1
+ """ot web:FastAPI 服务 + uvicorn 启动。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import webbrowser
8
+ from contextlib import asynccontextmanager
9
+ from pathlib import Path
10
+
11
+ from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
12
+ from fastapi.responses import FileResponse
13
+ from fastapi.staticfiles import StaticFiles
14
+
15
+ from ..shell.cmdset import extract_inline_secrets
16
+ from ..config import Config, TargetConfig
17
+ from ..connections import (
18
+ RenameConflictError, build_target_list, cred_alias_eq,
19
+ credential_referenced, delete_saved_target, display_name,
20
+ find_saved_target, load_saved_targets, parse_user_at_host,
21
+ rename_saved_target, upsert_saved_target,
22
+ )
23
+ from ..connections.secrets import delete_password, store_password
24
+ from ..frontend.render import console
25
+ from .files_api import install_files_errors, make_files_router
26
+ from .protocol import parse_client
27
+ from .worker import TabWorker
28
+
29
+ FRONTEND_DIR = Path(__file__).parent / "frontend"
30
+
31
+
32
+ def _conn_fields(payload: dict) -> tuple[str | None, str | None, int | None]:
33
+ """表单 payload → (host, user, port)。
34
+
35
+ 新表单直接给 host/user/port 字段;兼容旧的 {target: "user@host:port"}
36
+ 字符串(脚本/旧前端)。
37
+ """
38
+ if (payload.get("host") or "").strip():
39
+ host = str(payload["host"]).strip()
40
+ user = (payload.get("user") or "").strip() or None
41
+ port = payload.get("port")
42
+ try:
43
+ port = int(port) if port not in (None, "") else None
44
+ except (TypeError, ValueError):
45
+ port = None
46
+ return host, user, port
47
+ text = str(payload.get("target", "")).strip()
48
+ host, user, port = parse_user_at_host(text)
49
+ return (host or None), user, port
50
+
51
+
52
+ def _store_form_password(payload: dict, host: str, user: str | None,
53
+ port: int | None) -> None:
54
+ """表单带密码时存入系统凭据库(keyring);密码永不落 SQLite。"""
55
+ password = payload.get("password") or ""
56
+ if password:
57
+ store_password(host, user, port, password)
58
+ def _resolve_target_name(cfg: Config, text: str) -> str:
59
+ """目标名或 ad-hoc user@host 都解析成目标名。
60
+
61
+ 记住的连接(connections.db)按 name 直接注册(含 host/user/port),
62
+ 否则 ad-hoc user@host 重建临时目标。
63
+ """
64
+ if text in cfg.targets or text in {"local", "default"}:
65
+ return text
66
+ for t in load_saved_targets():
67
+ if t.name == text:
68
+ cfg.targets[text] = t
69
+ return text
70
+ host, user, port = parse_user_at_host(text)
71
+ name = text if user is None and port is None else host
72
+ cfg.targets.setdefault(
73
+ name, TargetConfig(name=name, mode="ssh", host=host, user=user, port=port))
74
+ return name
75
+
76
+
77
+ def create_app(cfg: Config, *, token: str = "") -> FastAPI:
78
+ tabs: dict[str, TabWorker] = {}
79
+ # 断线回收任务:浏览器刷新/断网后 ws 关闭,但 PTY/远端 shell 还活着;
80
+ # 给一段宽限期(可重连),到点仍无人挂上就真正回收
81
+ reapers: dict[str, asyncio.Task] = {}
82
+ _REAPER_GRACE = 300.0 # 5 分钟
83
+
84
+ async def _reap_later(tab_id: str) -> None:
85
+ await asyncio.sleep(_REAPER_GRACE)
86
+ reapers.pop(tab_id, None)
87
+ worker = tabs.get(tab_id)
88
+ # attach 时会取消本任务;这里再核一层 sink 兜底
89
+ if worker is not None and worker._sink is None:
90
+ tabs.pop(tab_id, None)
91
+ await worker.close()
92
+
93
+ def _cancel_reaper(tab_id: str) -> None:
94
+ t = reapers.pop(tab_id, None)
95
+ if t is not None:
96
+ t.cancel()
97
+
98
+ @asynccontextmanager
99
+ async def lifespan(_app):
100
+ yield
101
+ for t in reapers.values():
102
+ t.cancel()
103
+ for w in list(tabs.values()):
104
+ await w.close()
105
+
106
+ app = FastAPI(title="OpenTerminal Web", lifespan=lifespan)
107
+
108
+ @app.middleware("http")
109
+ async def _revalidate_static(request, call_next):
110
+ resp = await call_next(request)
111
+ # 前端资源强制每次校验(未变走 ETag 304),避免升级后浏览器沿用旧 JS/CSS
112
+ if request.url.path.startswith(("/static/", "/vendor/")):
113
+ resp.headers["Cache-Control"] = "no-cache"
114
+ return resp
115
+
116
+ def _check_token(headers) -> None:
117
+ if token and headers.get("x-ot-token") != token:
118
+ raise HTTPException(401, "token 缺失或不正确")
119
+
120
+ app.include_router(make_files_router(tabs, _check_token))
121
+ install_files_errors(app)
122
+
123
+ app.mount("/static", StaticFiles(directory=FRONTEND_DIR / "static"), name="static")
124
+ app.mount("/vendor", StaticFiles(directory=FRONTEND_DIR / "vendor"), name="vendor")
125
+
126
+ @app.get("/")
127
+ async def index():
128
+ return FileResponse(FRONTEND_DIR / "index.html")
129
+
130
+ @app.get("/api/targets")
131
+ async def targets(request: Request):
132
+ _check_token(request.headers)
133
+ return _target_payload()
134
+
135
+ @app.post("/api/tabs")
136
+ async def create_tab(request: Request, payload: dict):
137
+ _check_token(request.headers)
138
+ target = payload.get("target")
139
+ if not target:
140
+ raise HTTPException(400, "target required")
141
+ name = _resolve_target_name(cfg, target)
142
+ worker = TabWorker(cfg, name)
143
+ tabs[worker.tab_id] = worker
144
+ return {"tab_id": worker.tab_id}
145
+
146
+ @app.post("/api/tabs/{tab_id}/close")
147
+ async def close_tab(request: Request, tab_id: str):
148
+ _check_token(request.headers)
149
+ worker = tabs.pop(tab_id, None)
150
+ if worker is not None:
151
+ await worker.close() # 释放钩子(文件面板连接)在 close() 内统一跑
152
+ return {"ok": True}
153
+
154
+ @app.post("/api/saved")
155
+ async def add_saved(request: Request, payload: dict):
156
+ _check_token(request.headers)
157
+ host, user, port = _conn_fields(payload)
158
+ if not host:
159
+ raise HTTPException(400, "host required")
160
+ name = (payload.get("name") or "").strip() or display_name(host, user, port)
161
+ commands = None
162
+ resp: dict = {"ok": True, "name": name}
163
+ if "commands" in payload:
164
+ commands, failed = extract_inline_secrets(payload["commands"])
165
+ resp["commands"] = commands
166
+ if failed:
167
+ resp["warning"] = "凭据库不可用,以下密码未保存: " + ", ".join(failed)
168
+ upsert_saved_target(name=name, host=host, port=port, user=user,
169
+ commands=commands)
170
+ # 内存注册表同步失效:cfg.targets 里缓存的是旧配置(含命令集),
171
+ # 不弹出的话下次连接仍用缓存(真机:改了命令集连了却没生效)
172
+ cfg.targets.pop(name, None)
173
+ _store_form_password(payload, host, user, port)
174
+ return resp
175
+
176
+ @app.put("/api/saved/{name}")
177
+ async def edit_saved(request: Request, name: str, payload: dict):
178
+ _check_token(request.headers)
179
+ old = find_saved_target(name)
180
+ if old is None:
181
+ raise HTTPException(404, "unknown target")
182
+ host, user, port = _conn_fields(payload)
183
+ if not host:
184
+ raise HTTPException(400, "host required")
185
+ new_name = (payload.get("name") or "").strip() or name
186
+ commands = None
187
+ resp: dict = {"ok": True, "name": new_name}
188
+ if "commands" in payload:
189
+ commands, failed = extract_inline_secrets(payload["commands"])
190
+ resp["commands"] = commands
191
+ if failed:
192
+ resp["warning"] = "凭据库不可用,以下密码未保存: " + ", ".join(failed)
193
+ try:
194
+ removed = rename_saved_target(name, name=new_name, host=host,
195
+ port=port, user=user,
196
+ commands=commands)
197
+ except RenameConflictError:
198
+ # 新名字撞上另一条既有条目:拒绝写入(不再静默合并覆盖)
199
+ raise HTTPException(409, f"名称 {new_name} 已被另一条连接占用")
200
+ if removed is None:
201
+ # find 与 rename 之间条目被并发删除
202
+ raise HTTPException(404, "unknown target")
203
+ # 内存注册表同步失效(旧名可能已缓存/新名可能撞旧缓存)
204
+ cfg.targets.pop(name, None)
205
+ cfg.targets.pop(new_name, None)
206
+ if not cred_alias_eq((removed.host, removed.user, removed.port),
207
+ (host, user, port)):
208
+ # 地址变了(port None/22 视同没变——凭据键别名,改端口写法不
209
+ # 该作废密码)。表单密码留空 = 「留空不修改」:旧凭据原样保留
210
+ # (旧实现删了不写,静默丢);带新密码才清旧键,且仅当无其他
211
+ # 条目共享——三条跳板条目共用 pe@跳板 键,删一毁三(§2-6)
212
+ if (payload.get("password") or "") and not credential_referenced(
213
+ removed.host, removed.user, removed.port):
214
+ delete_password(removed.host, removed.user, removed.port)
215
+ _store_form_password(payload, host, user, port)
216
+ return resp
217
+
218
+ @app.delete("/api/saved/{name}")
219
+ async def remove_saved(request: Request, name: str):
220
+ _check_token(request.headers)
221
+ removed = delete_saved_target(name)
222
+ if removed is None:
223
+ raise HTTPException(404, "unknown target")
224
+ if not credential_referenced(removed.host, removed.user, removed.port):
225
+ # 共享键防误删:还有别的条目共用同一凭据(别名语义)就不动,
226
+ # 凭据仍服务其余条目;无引用才清(不留孤儿密码)
227
+ delete_password(removed.host, removed.user, removed.port)
228
+ cfg.targets.pop(name, None) # 内存注册表同步反注册
229
+ return {"ok": True}
230
+
231
+ @app.websocket("/ws/{tab_id}")
232
+ async def ws(ws: WebSocket, tab_id: str):
233
+ # token 校验:header 或 query(浏览器 WebSocket 无法设自定义 header,
234
+ # 前端以 ?token= 携带)
235
+ q_token = ws.query_params.get("token", "")
236
+ if token and ws.headers.get("x-ot-token") != token and q_token != token:
237
+ await ws.close(code=4401)
238
+ return
239
+ worker = tabs.get(tab_id)
240
+ if worker is None:
241
+ await ws.close(code=4404)
242
+ return
243
+ await ws.accept()
244
+ sink = _WebSink(ws)
245
+ _cancel_reaper(tab_id) # 重连成功:撤销待回收
246
+ worker.attach(sink)
247
+ try:
248
+ while True:
249
+ frame = await ws.receive()
250
+ kind = frame.get("type")
251
+ if kind == "websocket.disconnect":
252
+ break
253
+ raw = frame.get("bytes")
254
+ if raw is None:
255
+ raw = frame.get("text")
256
+ if raw is None:
257
+ continue
258
+ try:
259
+ await worker.handle_client(parse_client(raw))
260
+ except Exception: # noqa: BLE001 - 单条消息异常不打死连接
261
+ continue
262
+ except WebSocketDisconnect:
263
+ pass
264
+ finally:
265
+ worker.detach(sink)
266
+ if worker._sink is None:
267
+ # 断线/刷新:宽限期内前端可重连;到点仍无人挂上则关闭
268
+ # worker(PTY/远端 shell 随之释放),否则每次刷新都泄漏一份
269
+ reapers[tab_id] = asyncio.create_task(_reap_later(tab_id))
270
+
271
+ return app
272
+
273
+
274
+ class _WebSink:
275
+ def __init__(self, ws: WebSocket) -> None:
276
+ self._ws = ws
277
+
278
+ async def send_bytes(self, data: bytes) -> None:
279
+ await self._ws.send_bytes(data)
280
+
281
+ async def send_text(self, text: str) -> None:
282
+ await self._ws.send_text(text)
283
+
284
+
285
+ def _target_payload() -> dict:
286
+ tl = build_target_list()
287
+ return {
288
+ "local": tl.local,
289
+ "direct": [
290
+ {"name": t.name, "host": t.host, "user": t.user, "port": t.port,
291
+ "commands": "\n".join(t.commands)}
292
+ for t in tl.direct
293
+ ],
294
+ }
295
+
296
+
297
+ def run_web(cfg: Config, argv: list[str] | None = None) -> None:
298
+ import faulthandler
299
+ import signal
300
+ import uvicorn
301
+
302
+ if hasattr(faulthandler, "register") and hasattr(signal, "SIGUSR1"):
303
+ faulthandler.register(signal.SIGUSR1) # 临时排障:kill -USR1 <pid> 转储所有线程栈(POSIX)
304
+ argv = argv if argv is not None else []
305
+ p = argparse.ArgumentParser(prog="ot web")
306
+ p.add_argument("--host", default=cfg.web.host)
307
+ p.add_argument("--port", type=int, default=cfg.web.port)
308
+ p.add_argument("--token", default=cfg.web.token)
309
+ p.add_argument("--no-open", action="store_true")
310
+ args = p.parse_args(argv)
311
+
312
+ if args.host != "127.0.0.1" and not args.token:
313
+ console.print(
314
+ "[bold red]绑定非本机地址(--host {}) 时必须设置 --token 或 "
315
+ "config.toml [web] token,否则拒绝启动。[/]".format(args.host))
316
+ raise SystemExit(2)
317
+
318
+ app = create_app(cfg, token=args.token)
319
+ if not args.no_open:
320
+ webbrowser.open(f"http://127.0.0.1:{args.port}/")
321
+ console.print(f"[bold green]OpenTerminal Web[/] http://{args.host}:{args.port}/")
322
+ uvicorn.run(app, host=args.host, port=args.port)
@@ -0,0 +1,62 @@
1
+ """Web tab 适配器:PipelineCore 的 WebSocket 薄壳。
2
+
3
+ 单管线核心(PTY 直通 + hook 记账 + AI 任务)在 openterminal.core;本模块
4
+ 只负责:① emit_* 覆写为 outbox JSON 编码;② handle_client 别名(server.py
5
+ 调用面不变);③ 测试命名空间桥——tests/test_web_worker.py 历史上
6
+ monkeypatch 本模块的名字(wmod.open_session / SUBMIT_FALLBACK_DELAY /
7
+ CMDSET_* 等 28 处),实现搬去 core 后测试保持一行不改(阶段一验收硬线),
8
+ 故把 patch 写入同步转发到 core,monkeypatch teardown 走同一通道还原。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+
14
+ from .. import core as _core # noqa: F401 —— _PatchBridge 转发目标
15
+ # Task 9:改组件模块直连(不再经 core 包再导出)。测试 monkeypatch 打在
16
+ # wmod 的名字仍经 _PatchBridge → core._CoreBridge 二跳落到 pipeline 模块
17
+ # 字典,实现经 _impl.同名/模块全局读取——桥链语义不变。
18
+ from ..core.cmdset_runner import ( # noqa: F401 —— 测试 monkeypatch 命名空间
19
+ CMDSET_ECHO_TIMEOUT, CMDSET_QUIET, CMDSET_PROMPT_TIMEOUT,
20
+ CMDSET_RESUME_QUIET,
21
+ )
22
+ from ..core.messages import ( # noqa: F401
23
+ CHANNEL_DEAD_EC, CHANNEL_DEAD_MSG, ClientMsg, CommandResult,
24
+ InteractiveRunner, ServerMsg,
25
+ )
26
+ # _WebPresenter 为 CorePresenter 的旧名别名(tests/test_web_worker.py 直名
27
+ # import L340;InteractiveRunner/CommandResult 直名 import L699/722/740/757)。
28
+ from ..core.pipeline import ( # noqa: F401
29
+ CorePresenter as _WebPresenter, PipelineCore, SUBMIT_FALLBACK_DELAY,
30
+ _PROBE_TIMEOUT, build_agent, build_chat_model, load_password, probe_profile,
31
+ store_password,
32
+ )
33
+ from ..connections import open_session # noqa: F401
34
+ from .protocol import encode_server
35
+
36
+
37
+ class TabWorker(PipelineCore):
38
+ paced_display = True # Web:AI 文本攒到段边界一次性下发(ai_result)+ 心跳
39
+
40
+ async def emit_msg(self, msg: ServerMsg) -> None:
41
+ await self._outbox.put(("json", encode_server(msg)))
42
+
43
+ async def emit_bytes(self, data: bytes) -> None:
44
+ await self._outbox.put(("bytes", data))
45
+
46
+ def _emit_nowait(self, msg: ServerMsg) -> None:
47
+ self._outbox.put_nowait(("json", encode_server(msg)))
48
+
49
+ async def handle_client(self, msg: ClientMsg) -> None:
50
+ await self.feed_msg(msg)
51
+
52
+
53
+ class _PatchBridge(type(sys.modules[__name__])):
54
+ def __setattr__(cls, name, value):
55
+ super().__setattr__(name, value)
56
+ # dunder 不转发:__doc__/__name__ 等模块属性两模块各持一份,
57
+ # 单向桥会造成泄漏(pytest/monkeypatch 正常路径不会碰这些名字)
58
+ if not name.startswith("__") and hasattr(_core, name):
59
+ setattr(_core, name, value)
60
+
61
+
62
+ sys.modules[__name__].__class__ = _PatchBridge