procproxy 0.1.4__tar.gz → 0.1.4.2__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.
- {procproxy-0.1.4/src/procproxy.egg-info → procproxy-0.1.4.2}/PKG-INFO +24 -3
- {procproxy-0.1.4 → procproxy-0.1.4.2}/README.md +23 -2
- {procproxy-0.1.4 → procproxy-0.1.4.2}/docs/11-/350/260/203/350/257/225/346/216/222/351/224/231/346/211/213/345/206/214.md +10 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/docs/15-/347/274/226/347/250/213/346/216/245/345/217/243/344/270/216SDK.md +151 -5
- {procproxy-0.1.4 → procproxy-0.1.4.2}/examples/verify_sdk.py +22 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/pyproject.toml +1 -1
- {procproxy-0.1.4 → procproxy-0.1.4.2/src/procproxy.egg-info}/PKG-INFO +24 -3
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/procproxy.egg-info/SOURCES.txt +2 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/__init__.py +1 -1
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/api.py +54 -16
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/backend/windivert/__init__.py +17 -15
- procproxy-0.1.4.2/src/proxycore/background.py +461 -0
- procproxy-0.1.4.2/src/proxycore/errors.py +20 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/runtime.py +2 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/LICENSE +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/MANIFEST.in +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/docs/00-/346/236/266/346/236/204/344/270/216/351/230/205/350/257/273/350/267/257/347/272/277.md" +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/docs/07-/350/247/204/345/210/231/345/274/225/346/223/216/344/270/216Profile/346/240/274/345/274/217.md" +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/docs/14-/346/212/212/350/277/233/347/250/213/346/265/201/351/207/217/350/275/254/345/217/221/347/273/231/346/212/223/345/214/205/345/267/245/345/205/267.md" +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/examples/proxy_process.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/setup.cfg +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/procproxy.egg-info/dependency_links.txt +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/procproxy.egg-info/entry_points.txt +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/procproxy.egg-info/requires.txt +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/procproxy.egg-info/top_level.txt +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/__main__.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/backend/__init__.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/backend/base.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/backend/null.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/backend/windivert/filter.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/backend/windivert/pidmap.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/backend/windivert/pkt.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/canproxy.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/checkroute.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/cli.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/config.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/config_init.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/config_source.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/data/default.yaml +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/dns/__init__.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/dns/cache.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/dns/modes.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/dns/udp.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/forward.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/ipc.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/log.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/pathsetup.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/probe.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/proc.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/rules.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/service/__init__.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/service/__main__.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/tunnel/__init__.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/tunnel/relay.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/tunnel/sessions.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/tunnel/sink.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/types.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/upstream/__init__.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/upstream/base.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/upstream/chain.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/upstream/http_connect.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/upstream/socks4.py +0 -0
- {procproxy-0.1.4 → procproxy-0.1.4.2}/src/proxycore/upstream/socks5.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: procproxy
|
|
3
|
-
Version: 0.1.4
|
|
3
|
+
Version: 0.1.4.2
|
|
4
4
|
Summary: ProcProxy —— 进程级代理:按进程决定哪条连接走哪个上游代理
|
|
5
5
|
Author: Longersking
|
|
6
6
|
License-Expression: MIT
|
|
@@ -507,6 +507,24 @@ finally:
|
|
|
507
507
|
`start_proxy()` 会启动后台维护线程;`run_forever()` 会阻塞到 Ctrl+C。
|
|
508
508
|
请在主线程使用,并在退出时 `stop()`(或使用 `with`)。
|
|
509
509
|
|
|
510
|
+
### 后台常驻(父进程可退出)
|
|
511
|
+
|
|
512
|
+
```python
|
|
513
|
+
from proxycore.api import get_status, start_proxy, stop_proxy
|
|
514
|
+
|
|
515
|
+
bg = start_proxy("D:/cfg/chrome.yaml", background=True)
|
|
516
|
+
print(bg.pid, bg.config_path, bg.runtime_dir)
|
|
517
|
+
# 父进程可以退出;独立子进程继续运行
|
|
518
|
+
|
|
519
|
+
print(get_status(bg.runtime_dir)) # 跨进程读 state.json
|
|
520
|
+
print(stop_proxy(bg.runtime_dir)) # 写 control.json 并等待退出
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
- 状态目录默认 `~/.procproxy-app`(可用 `runtime_dir=` 或 `PROCPROXY_RUNTIME_DIR` 覆盖)
|
|
524
|
+
- 文件协议:`state.json`(数据面写)、`control.json`(应用写 `stop`/`panic`)
|
|
525
|
+
- `start_proxy(background=True, replace=True)` 可替换同目录下已在跑的数据面
|
|
526
|
+
- 子进程继承父进程权限;`backend="windivert"` 仍需管理员启动
|
|
527
|
+
|
|
510
528
|
### 错误处理
|
|
511
529
|
|
|
512
530
|
配置 / 后端 / 启动失败统一抛 `ProxyStartError`,`code` 是稳定的机器可读标识:
|
|
@@ -528,6 +546,9 @@ except ProxyStartError as exc:
|
|
|
528
546
|
| `config_version_unsupported` | `version != 1` |
|
|
529
547
|
| `rule_upstream_missing` 等 | 规则或上游校验失败(完整列表见文档) |
|
|
530
548
|
| `E_BACKEND_UNAVAILABLE` | 后端不可用(未知后端、WinDivert 不可加载等) |
|
|
549
|
+
| `already_running` | 后台模式同一 runtime_dir 已有存活数据面 |
|
|
550
|
+
| `background_start_failed` / `background_start_timeout` | 后台子进程启动失败 / 超时 |
|
|
551
|
+
| `runtime_dir_unavailable` | 后台状态目录无法创建或写入 |
|
|
531
552
|
|
|
532
553
|
`api.load_config()` 同样抛 `ProxyStartError`,不是 `ConfigError`。
|
|
533
554
|
|
|
@@ -590,7 +611,7 @@ src/proxycore/ 核心包
|
|
|
590
611
|
samples/ mock 服务 + 示例配置
|
|
591
612
|
examples/ 纯 Python 示例(proxy_process.py / verify_sdk.py)
|
|
592
613
|
docs/ 文档(00 架构 / 07 规则 / 11 排错 / 14 抓包 / 15 SDK)
|
|
593
|
-
tests/ 测试套件(
|
|
614
|
+
tests/ 测试套件(633 passed / 4 skipped)
|
|
594
615
|
└── tools/ 验证脚本(run_demo / run_e2e / bench / …)
|
|
595
616
|
```
|
|
596
617
|
|
|
@@ -621,7 +642,7 @@ tests/ 测试套件(624 passed / 4 skipped)
|
|
|
621
642
|
|
|
622
643
|
```bash
|
|
623
644
|
pip install -e ".[dev]"
|
|
624
|
-
python -m pytest -q tests/ #
|
|
645
|
+
python -m pytest -q tests/ # 633 passed / 4 skipped(非管理员 Windows)
|
|
625
646
|
python -m ruff check src tests
|
|
626
647
|
python -m mypy src/proxycore
|
|
627
648
|
```
|
|
@@ -468,6 +468,24 @@ finally:
|
|
|
468
468
|
`start_proxy()` 会启动后台维护线程;`run_forever()` 会阻塞到 Ctrl+C。
|
|
469
469
|
请在主线程使用,并在退出时 `stop()`(或使用 `with`)。
|
|
470
470
|
|
|
471
|
+
### 后台常驻(父进程可退出)
|
|
472
|
+
|
|
473
|
+
```python
|
|
474
|
+
from proxycore.api import get_status, start_proxy, stop_proxy
|
|
475
|
+
|
|
476
|
+
bg = start_proxy("D:/cfg/chrome.yaml", background=True)
|
|
477
|
+
print(bg.pid, bg.config_path, bg.runtime_dir)
|
|
478
|
+
# 父进程可以退出;独立子进程继续运行
|
|
479
|
+
|
|
480
|
+
print(get_status(bg.runtime_dir)) # 跨进程读 state.json
|
|
481
|
+
print(stop_proxy(bg.runtime_dir)) # 写 control.json 并等待退出
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
- 状态目录默认 `~/.procproxy-app`(可用 `runtime_dir=` 或 `PROCPROXY_RUNTIME_DIR` 覆盖)
|
|
485
|
+
- 文件协议:`state.json`(数据面写)、`control.json`(应用写 `stop`/`panic`)
|
|
486
|
+
- `start_proxy(background=True, replace=True)` 可替换同目录下已在跑的数据面
|
|
487
|
+
- 子进程继承父进程权限;`backend="windivert"` 仍需管理员启动
|
|
488
|
+
|
|
471
489
|
### 错误处理
|
|
472
490
|
|
|
473
491
|
配置 / 后端 / 启动失败统一抛 `ProxyStartError`,`code` 是稳定的机器可读标识:
|
|
@@ -489,6 +507,9 @@ except ProxyStartError as exc:
|
|
|
489
507
|
| `config_version_unsupported` | `version != 1` |
|
|
490
508
|
| `rule_upstream_missing` 等 | 规则或上游校验失败(完整列表见文档) |
|
|
491
509
|
| `E_BACKEND_UNAVAILABLE` | 后端不可用(未知后端、WinDivert 不可加载等) |
|
|
510
|
+
| `already_running` | 后台模式同一 runtime_dir 已有存活数据面 |
|
|
511
|
+
| `background_start_failed` / `background_start_timeout` | 后台子进程启动失败 / 超时 |
|
|
512
|
+
| `runtime_dir_unavailable` | 后台状态目录无法创建或写入 |
|
|
492
513
|
|
|
493
514
|
`api.load_config()` 同样抛 `ProxyStartError`,不是 `ConfigError`。
|
|
494
515
|
|
|
@@ -551,7 +572,7 @@ src/proxycore/ 核心包
|
|
|
551
572
|
samples/ mock 服务 + 示例配置
|
|
552
573
|
examples/ 纯 Python 示例(proxy_process.py / verify_sdk.py)
|
|
553
574
|
docs/ 文档(00 架构 / 07 规则 / 11 排错 / 14 抓包 / 15 SDK)
|
|
554
|
-
tests/ 测试套件(
|
|
575
|
+
tests/ 测试套件(633 passed / 4 skipped)
|
|
555
576
|
└── tools/ 验证脚本(run_demo / run_e2e / bench / …)
|
|
556
577
|
```
|
|
557
578
|
|
|
@@ -582,7 +603,7 @@ tests/ 测试套件(624 passed / 4 skipped)
|
|
|
582
603
|
|
|
583
604
|
```bash
|
|
584
605
|
pip install -e ".[dev]"
|
|
585
|
-
python -m pytest -q tests/ #
|
|
606
|
+
python -m pytest -q tests/ # 633 passed / 4 skipped(非管理员 Windows)
|
|
586
607
|
python -m ruff check src tests
|
|
587
608
|
python -m mypy src/proxycore
|
|
588
609
|
```
|
|
@@ -100,6 +100,16 @@ sc stop WinDivert # 极端情况:直接卸掉 WinDive
|
|
|
100
100
|
|
|
101
101
|
> `version != 1` 仍然报 `config_version_unsupported`,与读取错误分开。
|
|
102
102
|
|
|
103
|
+
### 2.9 升级 pydivert 4.0.0 后,改道成功但连接卡在 SYN_SENT
|
|
104
|
+
|
|
105
|
+
| 项 | 内容 |
|
|
106
|
+
|---|---|
|
|
107
|
+
| 症状 | 日志有 `redirect`,但应用一直 SYN_SENT、curl 超时、sink/Yakit 收不到包;上游 0 条 CONNECT |
|
|
108
|
+
| 根因 | pydivert 4.0.0 的 `send()` 默认调用 `recalculate_checksums()`,后者在 Windows 上先执行 `_populate_wd_addr()`,按**构造期缓存**重建 `WINDIVERT_ADDRESS`。旧实现先构造 `Packet` 再直接改 `wd_addr.Loopback/IfIdx`,重建时被清零 → 回环 SYN 被内核丢弃。3.1.3 不做这次重建,所以 3.x 正常 |
|
|
109
|
+
| 修复 | 0.1.4.1 起:`Loopback` / `IfIdx` / `direction` 通过 `Packet(...)` 构造参数传入,并且 `send(..., recalculate_checksum=False)`(校验和已由 `pkt.py` 算好);同时兼容 3.x / 4.x |
|
|
110
|
+
| 快速确认 | `python -c "import pydivert; print(pydivert.__version__)"`;4.x 且有上述症状就升级 proxycore |
|
|
111
|
+
| 临时绕过 | 不要长期 monkey patch;可在验证阶段把 pydivert 固定到 `3.1.3`,或直接使用含修复的 proxycore |
|
|
112
|
+
|
|
103
113
|
## 3. 抓包与验证技巧
|
|
104
114
|
|
|
105
115
|
```powershell
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
> 发行包名是 `procproxy`,导入名是 `proxycore`。`pip install procproxy` 同时安装
|
|
6
6
|
> 库和可选的 CLI;CLI 只是库的外壳,不使用它也能完成初始化、代理、查询和停止。
|
|
7
7
|
|
|
8
|
-
本文既是使用教程,也是**行为契约**。对应 `proxycore 0.1.4`;§10 的验收脚本
|
|
9
|
-
`examples/verify_sdk.py`(
|
|
8
|
+
本文既是使用教程,也是**行为契约**。对应 `proxycore 0.1.4.2`;§10 的验收脚本
|
|
9
|
+
`examples/verify_sdk.py`(31 项断言,含后台进程)可以直接用来检查一份实现是否与本文一致。
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
@@ -429,7 +429,111 @@ ppx.stop() # 或在 with 退出时自动停止
|
|
|
429
429
|
|
|
430
430
|
停止后不再捕获新连接;已经建立的连接由关闭流程处理。
|
|
431
431
|
|
|
432
|
-
### 6.7
|
|
432
|
+
### 6.7 后台常驻:父进程退出后继续运行
|
|
433
|
+
|
|
434
|
+
适合"应用只负责拉起数据面,随后退出"的场景。
|
|
435
|
+
|
|
436
|
+
```python
|
|
437
|
+
from proxycore.api import get_status, start_proxy, stop_proxy
|
|
438
|
+
|
|
439
|
+
bg = start_proxy("D:/cfg/chrome.yaml", background=True)
|
|
440
|
+
print(bg.pid) # 后台数据面 PID
|
|
441
|
+
print(bg.config_path) # 生效配置的绝对路径
|
|
442
|
+
print(bg.runtime_dir) # state.json / control.json 所在目录
|
|
443
|
+
|
|
444
|
+
# 父进程可以退出;数据面继续运行
|
|
445
|
+
state = get_status(bg.runtime_dir) # 读 state.json(跨进程、只读文件)
|
|
446
|
+
result = stop_proxy(bg.runtime_dir) # 写 control.json,等待子进程退出
|
|
447
|
+
print(result.to_dict())
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
`start_proxy(background=True)` 的完整签名(其余参数与前台一致):
|
|
451
|
+
|
|
452
|
+
```python
|
|
453
|
+
def start_proxy(
|
|
454
|
+
config=None,
|
|
455
|
+
*,
|
|
456
|
+
backend="",
|
|
457
|
+
sink_test_target="",
|
|
458
|
+
upstream="",
|
|
459
|
+
log_level="",
|
|
460
|
+
log_jsonl="",
|
|
461
|
+
background: bool = False,
|
|
462
|
+
runtime_dir: str | os.PathLike[str] | None = None,
|
|
463
|
+
wait_timeout_s: float = 10.0,
|
|
464
|
+
replace: bool = False,
|
|
465
|
+
) -> ProxyHandle | BackgroundProxy: ...
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
返回的 `BackgroundProxy`:
|
|
469
|
+
|
|
470
|
+
| 字段 | 类型 | 说明 |
|
|
471
|
+
|---|---|---|
|
|
472
|
+
| `pid` | `int` | 后台数据面进程 PID |
|
|
473
|
+
| `config_path` | `str` | 子进程实际加载的配置绝对路径 |
|
|
474
|
+
| `runtime_dir` | `str` | 状态/控制文件目录 |
|
|
475
|
+
| `state_path` | `str` | `<runtime_dir>/state.json` |
|
|
476
|
+
| `control_path` | `str` | `<runtime_dir>/control.json` |
|
|
477
|
+
| `log_path` | `str` | `<runtime_dir>/daemon.log`(子进程 stdout/stderr) |
|
|
478
|
+
| `backend` | `str` | `null` / `windivert` |
|
|
479
|
+
| `started_at` | `float` | 启动时间戳 |
|
|
480
|
+
|
|
481
|
+
`BackgroundProxy.to_dict()` 返回上述字段的字典。
|
|
482
|
+
|
|
483
|
+
#### 文件协议(与 CLI 完全一致)
|
|
484
|
+
|
|
485
|
+
| 文件 | 方向 | 内容 |
|
|
486
|
+
|---|---|---|
|
|
487
|
+
| `state.json` | 数据面 → 应用 | 周期性状态;含 `pid`、`backend`、`started_at`、`config`、会话/计数等 |
|
|
488
|
+
| `control.json` | 应用 → 数据面 | `{"action": "stop"}` 或 `{"action": "panic", "ts": ...}`;数据面 1 秒内消费并删除 |
|
|
489
|
+
| `sessions.jsonl` | 数据面 → 应用 | 会话日志(CLI 默认路径) |
|
|
490
|
+
| `daemon.log` | 数据面 → 应用 | 子进程 stdout/stderr,启动失败时看这里 |
|
|
491
|
+
|
|
492
|
+
`runtime_dir` 解析顺序:
|
|
493
|
+
|
|
494
|
+
1. `start_proxy(..., runtime_dir=...)` 显式参数;
|
|
495
|
+
2. 环境变量 `PROCPROXY_RUNTIME_DIR`;
|
|
496
|
+
3. `~/.procproxy-app`。
|
|
497
|
+
|
|
498
|
+
子进程启动时会被显式设置 `PROCPROXY_RUNTIME_DIR=<解析后的绝对路径>`,
|
|
499
|
+
所以父进程和应用侧的 `get_status` / `stop_proxy` 只要使用同一个目录即可。
|
|
500
|
+
|
|
501
|
+
#### 行为细节
|
|
502
|
+
|
|
503
|
+
- **父进程退出不影响子进程**:Windows 使用 `DETACHED_PROCESS`,POSIX 使用
|
|
504
|
+
`start_new_session=True`;子进程 stdout/stderr 重定向到 `daemon.log`;
|
|
505
|
+
- **配置对象会被落盘**:传 `Config` 对象时写到 `<runtime_dir>/config.yaml`,
|
|
506
|
+
再由子进程加载;传路径时直接使用该绝对路径;
|
|
507
|
+
- **重复启动**:同一 `runtime_dir` 已有存活数据面时抛
|
|
508
|
+
`ProxyStartError(code="already_running")`;`replace=True` 会先停止旧进程;
|
|
509
|
+
- **启动失败**:子进程退出或超时分别抛 `background_start_failed` /
|
|
510
|
+
`background_start_timeout`,异常文本附带 `daemon.log` 最后 20 行;
|
|
511
|
+
- **权限**:子进程继承父进程权限;`backend="windivert"` 仍然要求父进程以管理员启动;
|
|
512
|
+
- **停止不强制杀进程**:`stop_proxy` 只写控制文件并等待;超时返回
|
|
513
|
+
`StopResult(stopped=False, reason="timeout")`,不会静默杀掉别人的进程。
|
|
514
|
+
|
|
515
|
+
#### 跨进程查询/停止的独立函数
|
|
516
|
+
|
|
517
|
+
```python
|
|
518
|
+
from proxycore.api import get_status, is_running, resolve_runtime_dir, stop_proxy
|
|
519
|
+
|
|
520
|
+
get_status(runtime_dir=None) -> dict | None # 读 state.json;不存在/非法返回 None
|
|
521
|
+
is_running(runtime_dir=None) -> bool # state.json 的 pid 是否存活
|
|
522
|
+
stop_proxy(runtime_dir=None, *, timeout_s=10.0) -> StopResult
|
|
523
|
+
resolve_runtime_dir(runtime_dir=None) -> Path # 显式 → 环境变量 → ~/.procproxy-app
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
`StopResult` 字段:`stopped: bool`、`pid: int`、`runtime_dir: str`、
|
|
527
|
+
`control_path: str`、`reason: str`(`stopped` / `not_running` / `timeout`)。
|
|
528
|
+
|
|
529
|
+
> 你现有的 `get_status` / `stop_proxy` 只要使用同一个 `runtime_dir` 和上述
|
|
530
|
+
> 文件名,就与 SDK 完全兼容;不需要改 state.json / control.json 的格式。
|
|
531
|
+
>
|
|
532
|
+
> CLI 的 `procproxy stop` 使用 CLI 自己的 runtime_dir;要停止 SDK 后台数据面,
|
|
533
|
+
> 请设置 `PROCPROXY_RUNTIME_DIR=<runtime_dir>` 后再执行,或直接调用
|
|
534
|
+
> `proxycore.api.stop_proxy(runtime_dir)`。
|
|
535
|
+
|
|
536
|
+
### 6.8 没有管理员怎么办
|
|
433
537
|
|
|
434
538
|
只能做联调:
|
|
435
539
|
|
|
@@ -629,6 +733,41 @@ class ProxyStartError(RuntimeError):
|
|
|
629
733
|
|
|
630
734
|
`str(exc)` 是给人看的中文信息;程序判断请用 `exc.code`。
|
|
631
735
|
|
|
736
|
+
### 7.8 后台常驻 API
|
|
737
|
+
|
|
738
|
+
```python
|
|
739
|
+
def start_proxy_background(
|
|
740
|
+
config: Config | str | os.PathLike[str] | None = None,
|
|
741
|
+
*,
|
|
742
|
+
backend: str = "",
|
|
743
|
+
sink_test_target: str = "",
|
|
744
|
+
upstream: str = "",
|
|
745
|
+
log_level: str = "",
|
|
746
|
+
log_jsonl: str = "",
|
|
747
|
+
runtime_dir: str | os.PathLike[str] | None = None,
|
|
748
|
+
wait_timeout_s: float = 10.0,
|
|
749
|
+
replace: bool = False,
|
|
750
|
+
) -> BackgroundProxy: ...
|
|
751
|
+
|
|
752
|
+
def get_status(runtime_dir=None) -> dict | None: ...
|
|
753
|
+
def is_running(runtime_dir=None) -> bool: ...
|
|
754
|
+
def stop_proxy(runtime_dir=None, *, timeout_s: float = 10.0) -> StopResult: ...
|
|
755
|
+
def resolve_runtime_dir(runtime_dir=None) -> Path: ...
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
`start_proxy(..., background=True, runtime_dir=..., wait_timeout_s=..., replace=...)`
|
|
759
|
+
是同一入口;`start_proxy_background()` 便于类型明确的调用方直接使用。
|
|
760
|
+
|
|
761
|
+
异常码:
|
|
762
|
+
|
|
763
|
+
| code | 触发条件 |
|
|
764
|
+
|---|---|
|
|
765
|
+
| `already_running` | 同一 runtime_dir 已有存活数据面,且 `replace=False` |
|
|
766
|
+
| `background_start_failed` | 子进程在写出 state.json 前退出(附 daemon.log 末尾) |
|
|
767
|
+
| `background_start_timeout` | 等待 state.json 超时;子进程会被终止 |
|
|
768
|
+
| `runtime_dir_unavailable` | runtime_dir 无法创建/写入 |
|
|
769
|
+
| `config_not_found` 等 | 配置阶段失败,与前台一致 |
|
|
770
|
+
|
|
632
771
|
---
|
|
633
772
|
|
|
634
773
|
## 8. 配置参考
|
|
@@ -783,7 +922,7 @@ udp_policy:
|
|
|
783
922
|
## 10. 验收清单(给实现方/文档校验)
|
|
784
923
|
|
|
785
924
|
仓库里的 [`examples/verify_sdk.py`](https://github.com/Longersking/procproxy/blob/master/examples/verify_sdk.py)
|
|
786
|
-
是一个
|
|
925
|
+
是一个 31 项断言的可执行契约:
|
|
787
926
|
|
|
788
927
|
```bash
|
|
789
928
|
python examples/verify_sdk.py
|
|
@@ -802,7 +941,10 @@ python examples/verify_sdk.py
|
|
|
802
941
|
9. `start_proxy(path)` 的 `config_source == "explicit"`;
|
|
803
942
|
10. `status()` 含 `backend/rules/sessions/breaker/dns/udp/config_path/config_source/running`;
|
|
804
943
|
11. `reload()` 返回递增的 generation;
|
|
805
|
-
12. 显式路径不存在 → `ProxyStartError.code == "config_not_found"
|
|
944
|
+
12. 显式路径不存在 → `ProxyStartError.code == "config_not_found"`;
|
|
945
|
+
13. `start_proxy(background=True)` 返回 pid/config_path/runtime_dir;
|
|
946
|
+
14. `get_status()` 能跨进程读到同一个 pid;
|
|
947
|
+
15. `is_running()` 为 True,`stop_proxy()` 写 control.json 后变为 False。
|
|
806
948
|
|
|
807
949
|
参考实现:
|
|
808
950
|
|
|
@@ -833,6 +975,10 @@ python examples/verify_sdk.py
|
|
|
833
975
|
| `profile_option_unsupported` | `handle_services` / `handle_other_users=true` |
|
|
834
976
|
| `E_BACKEND_UNAVAILABLE` | 未知后端、非管理员、驱动不可加载 |
|
|
835
977
|
| `config_path_missing` | 对内存配置调用无参 `reload()` |
|
|
978
|
+
| `already_running` | 后台模式同一 runtime_dir 已有存活数据面 |
|
|
979
|
+
| `background_start_failed` | 后台子进程在写出 state.json 前退出 |
|
|
980
|
+
| `background_start_timeout` | 后台子进程未在超时内写出 state.json |
|
|
981
|
+
| `runtime_dir_unavailable` | 后台 runtime_dir 无法创建/写入 |
|
|
836
982
|
|
|
837
983
|
`config_unknown_field` 只出现在 `Config.warnings`,不阻止加载。
|
|
838
984
|
|
|
@@ -25,9 +25,12 @@ os.environ.pop("PROCPROXY_CONFIG", None)
|
|
|
25
25
|
from proxycore import __version__ # noqa: E402
|
|
26
26
|
from proxycore.api import ( # noqa: E402
|
|
27
27
|
ProxyStartError,
|
|
28
|
+
get_status,
|
|
28
29
|
init_config,
|
|
30
|
+
is_running,
|
|
29
31
|
make_runtime,
|
|
30
32
|
start_proxy,
|
|
33
|
+
stop_proxy,
|
|
31
34
|
)
|
|
32
35
|
from proxycore.config import Config, atomic_write # noqa: E402
|
|
33
36
|
from proxycore.types import ConnRequest, Endpoint, ProcInfo, Proto # noqa: E402
|
|
@@ -143,6 +146,25 @@ def main() -> int:
|
|
|
143
146
|
else:
|
|
144
147
|
raise AssertionError("缺失配置未被拒绝")
|
|
145
148
|
|
|
149
|
+
# 7. 后台常驻:独立进程 + state.json / control.json
|
|
150
|
+
bg_config = write_config(work / "background.yaml", port=19135)
|
|
151
|
+
bg = start_proxy(
|
|
152
|
+
bg_config,
|
|
153
|
+
background=True,
|
|
154
|
+
runtime_dir=work / "background-rt",
|
|
155
|
+
wait_timeout_s=20.0,
|
|
156
|
+
)
|
|
157
|
+
try:
|
|
158
|
+
check("background pid", bg.pid > 0, str(bg.pid))
|
|
159
|
+
check("background config path", bg.config_path == str(bg_config.resolve()))
|
|
160
|
+
state = get_status(bg.runtime_dir)
|
|
161
|
+
check("background state pid", bool(state) and state.get("pid") == bg.pid)
|
|
162
|
+
check("background is_running", is_running(bg.runtime_dir))
|
|
163
|
+
finally:
|
|
164
|
+
result = stop_proxy(bg.runtime_dir, timeout_s=20.0)
|
|
165
|
+
check("background stopped", result.stopped, result.reason)
|
|
166
|
+
check("background not running", not is_running(bg.runtime_dir))
|
|
167
|
+
|
|
146
168
|
print(f"\n全部通过:{len(PASSED)} 项检查")
|
|
147
169
|
for name in PASSED:
|
|
148
170
|
print(f" [OK] {name}")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: procproxy
|
|
3
|
-
Version: 0.1.4
|
|
3
|
+
Version: 0.1.4.2
|
|
4
4
|
Summary: ProcProxy —— 进程级代理:按进程决定哪条连接走哪个上游代理
|
|
5
5
|
Author: Longersking
|
|
6
6
|
License-Expression: MIT
|
|
@@ -507,6 +507,24 @@ finally:
|
|
|
507
507
|
`start_proxy()` 会启动后台维护线程;`run_forever()` 会阻塞到 Ctrl+C。
|
|
508
508
|
请在主线程使用,并在退出时 `stop()`(或使用 `with`)。
|
|
509
509
|
|
|
510
|
+
### 后台常驻(父进程可退出)
|
|
511
|
+
|
|
512
|
+
```python
|
|
513
|
+
from proxycore.api import get_status, start_proxy, stop_proxy
|
|
514
|
+
|
|
515
|
+
bg = start_proxy("D:/cfg/chrome.yaml", background=True)
|
|
516
|
+
print(bg.pid, bg.config_path, bg.runtime_dir)
|
|
517
|
+
# 父进程可以退出;独立子进程继续运行
|
|
518
|
+
|
|
519
|
+
print(get_status(bg.runtime_dir)) # 跨进程读 state.json
|
|
520
|
+
print(stop_proxy(bg.runtime_dir)) # 写 control.json 并等待退出
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
- 状态目录默认 `~/.procproxy-app`(可用 `runtime_dir=` 或 `PROCPROXY_RUNTIME_DIR` 覆盖)
|
|
524
|
+
- 文件协议:`state.json`(数据面写)、`control.json`(应用写 `stop`/`panic`)
|
|
525
|
+
- `start_proxy(background=True, replace=True)` 可替换同目录下已在跑的数据面
|
|
526
|
+
- 子进程继承父进程权限;`backend="windivert"` 仍需管理员启动
|
|
527
|
+
|
|
510
528
|
### 错误处理
|
|
511
529
|
|
|
512
530
|
配置 / 后端 / 启动失败统一抛 `ProxyStartError`,`code` 是稳定的机器可读标识:
|
|
@@ -528,6 +546,9 @@ except ProxyStartError as exc:
|
|
|
528
546
|
| `config_version_unsupported` | `version != 1` |
|
|
529
547
|
| `rule_upstream_missing` 等 | 规则或上游校验失败(完整列表见文档) |
|
|
530
548
|
| `E_BACKEND_UNAVAILABLE` | 后端不可用(未知后端、WinDivert 不可加载等) |
|
|
549
|
+
| `already_running` | 后台模式同一 runtime_dir 已有存活数据面 |
|
|
550
|
+
| `background_start_failed` / `background_start_timeout` | 后台子进程启动失败 / 超时 |
|
|
551
|
+
| `runtime_dir_unavailable` | 后台状态目录无法创建或写入 |
|
|
531
552
|
|
|
532
553
|
`api.load_config()` 同样抛 `ProxyStartError`,不是 `ConfigError`。
|
|
533
554
|
|
|
@@ -590,7 +611,7 @@ src/proxycore/ 核心包
|
|
|
590
611
|
samples/ mock 服务 + 示例配置
|
|
591
612
|
examples/ 纯 Python 示例(proxy_process.py / verify_sdk.py)
|
|
592
613
|
docs/ 文档(00 架构 / 07 规则 / 11 排错 / 14 抓包 / 15 SDK)
|
|
593
|
-
tests/ 测试套件(
|
|
614
|
+
tests/ 测试套件(633 passed / 4 skipped)
|
|
594
615
|
└── tools/ 验证脚本(run_demo / run_e2e / bench / …)
|
|
595
616
|
```
|
|
596
617
|
|
|
@@ -621,7 +642,7 @@ tests/ 测试套件(624 passed / 4 skipped)
|
|
|
621
642
|
|
|
622
643
|
```bash
|
|
623
644
|
pip install -e ".[dev]"
|
|
624
|
-
python -m pytest -q tests/ #
|
|
645
|
+
python -m pytest -q tests/ # 633 passed / 4 skipped(非管理员 Windows)
|
|
625
646
|
python -m ruff check src tests
|
|
626
647
|
python -m mypy src/proxycore
|
|
627
648
|
```
|
|
@@ -18,12 +18,14 @@ src/procproxy.egg-info/top_level.txt
|
|
|
18
18
|
src/proxycore/__init__.py
|
|
19
19
|
src/proxycore/__main__.py
|
|
20
20
|
src/proxycore/api.py
|
|
21
|
+
src/proxycore/background.py
|
|
21
22
|
src/proxycore/canproxy.py
|
|
22
23
|
src/proxycore/checkroute.py
|
|
23
24
|
src/proxycore/cli.py
|
|
24
25
|
src/proxycore/config.py
|
|
25
26
|
src/proxycore/config_init.py
|
|
26
27
|
src/proxycore/config_source.py
|
|
28
|
+
src/proxycore/errors.py
|
|
27
29
|
src/proxycore/forward.py
|
|
28
30
|
src/proxycore/ipc.py
|
|
29
31
|
src/proxycore/log.py
|
|
@@ -49,9 +49,19 @@ from dataclasses import dataclass
|
|
|
49
49
|
from pathlib import Path
|
|
50
50
|
from typing import Any
|
|
51
51
|
|
|
52
|
+
from .background import (
|
|
53
|
+
BackgroundProxy,
|
|
54
|
+
StopResult,
|
|
55
|
+
get_status,
|
|
56
|
+
is_running,
|
|
57
|
+
resolve_runtime_dir,
|
|
58
|
+
start_proxy_background,
|
|
59
|
+
stop_proxy,
|
|
60
|
+
)
|
|
52
61
|
from .config import Config, ConfigError, ConfigManager
|
|
53
62
|
from .config_init import create_config_file
|
|
54
63
|
from .config_source import ORIGIN_EXPLICIT, ORIGIN_OBJECT, normalize_config_path, resolve_config_source
|
|
64
|
+
from .errors import ProxyStartError
|
|
55
65
|
from .log import EventLogger, build_logger, get_logger
|
|
56
66
|
from .runtime import Runtime
|
|
57
67
|
from .types import ConnRequest, Decision
|
|
@@ -59,18 +69,6 @@ from .types import ConnRequest, Decision
|
|
|
59
69
|
LOG = EventLogger(get_logger("proxycore.api"))
|
|
60
70
|
|
|
61
71
|
|
|
62
|
-
class ProxyStartError(RuntimeError):
|
|
63
|
-
"""启动失败(配置非法 / 后端不可用 / sink 端口占用)。
|
|
64
|
-
|
|
65
|
-
`code` 是稳定的机器可读标识:配置错误沿用 `ConfigError.code`
|
|
66
|
-
(如 `config_not_found`),后端不可用为 `E_BACKEND_UNAVAILABLE`。
|
|
67
|
-
"""
|
|
68
|
-
|
|
69
|
-
def __init__(self, message: str, *, code: str = "") -> None:
|
|
70
|
-
super().__init__(message)
|
|
71
|
-
self.code = code
|
|
72
|
-
|
|
73
|
-
|
|
74
72
|
def _wrap_config_error(exc: ConfigError) -> ProxyStartError:
|
|
75
73
|
return ProxyStartError(f"配置无效:[{exc.code}] {exc.message}", code=exc.code)
|
|
76
74
|
|
|
@@ -285,17 +283,45 @@ def start_proxy(
|
|
|
285
283
|
upstream: str = "",
|
|
286
284
|
log_level: str = "",
|
|
287
285
|
log_jsonl: str = "",
|
|
288
|
-
|
|
289
|
-
|
|
286
|
+
background: bool = False,
|
|
287
|
+
runtime_dir: str | os.PathLike[str] | None = None,
|
|
288
|
+
wait_timeout_s: float = 10.0,
|
|
289
|
+
replace: bool = False,
|
|
290
|
+
) -> ProxyHandle | BackgroundProxy:
|
|
291
|
+
"""启动 ProcProxy 数据面。
|
|
292
|
+
|
|
293
|
+
前台(默认,`background=False`):在当前进程里启动,返回 `ProxyHandle`;
|
|
294
|
+
进程退出即停止,适合 `with` / 嵌入程序 / 测试。
|
|
295
|
+
|
|
296
|
+
后台(`background=True`):拉起独立子进程并立即返回 `BackgroundProxy`
|
|
297
|
+
(含 `pid` / `config_path` / `runtime_dir` / `state_path` / `control_path`)。
|
|
298
|
+
父进程可以退出,数据面继续运行;用 `get_status()` 查询、`stop_proxy()` 停止。
|
|
299
|
+
子进程的 `PROCPROXY_RUNTIME_DIR` 固定为解析后的 runtime_dir
|
|
300
|
+
(默认 `~/.procproxy-app`),状态/控制文件跨进程一致。
|
|
301
|
+
|
|
302
|
+
其它参数:
|
|
290
303
|
|
|
291
304
|
* `config=None`:按来源优先级自动选择;pip 安装后默认使用内置配置
|
|
292
305
|
(`backend=null`,不抓包、不改道),且不会创建任何文件;
|
|
293
|
-
* 返回的 `ProxyHandle` 支持 `with`,退出会自动 stop;
|
|
294
306
|
* `backend=""` 用配置里的 backend;`backend="null"` 用于联调(不抓包);
|
|
295
307
|
* `sink_test_target` 非空 = 测试模式:sink 把所有连接转发到该固定目标;
|
|
296
308
|
注意 backend=null 且不传 `sink_test_target`/`upstream` 时**不会**监听 sink;
|
|
297
|
-
* 后端不可用(WinDivert 不可加载/非管理员)→ 抛 `ProxyStartError
|
|
309
|
+
* 后端不可用(WinDivert 不可加载/非管理员)→ 抛 `ProxyStartError`,不会静默直连;
|
|
310
|
+
* 后台模式的 `replace=True`:同一 runtime_dir 已有存活数据面时先停掉再启动,
|
|
311
|
+
否则抛 `already_running`。
|
|
298
312
|
"""
|
|
313
|
+
if background:
|
|
314
|
+
return start_proxy_background(
|
|
315
|
+
config,
|
|
316
|
+
backend=backend,
|
|
317
|
+
sink_test_target=sink_test_target,
|
|
318
|
+
upstream=upstream,
|
|
319
|
+
log_level=log_level,
|
|
320
|
+
log_jsonl=log_jsonl,
|
|
321
|
+
runtime_dir=runtime_dir,
|
|
322
|
+
wait_timeout_s=wait_timeout_s,
|
|
323
|
+
replace=replace,
|
|
324
|
+
)
|
|
299
325
|
runtime = make_runtime(
|
|
300
326
|
config,
|
|
301
327
|
backend=backend,
|
|
@@ -325,6 +351,11 @@ def start_proxy(
|
|
|
325
351
|
def run_forever(config: Config | str | os.PathLike[str] | None = None, **kwargs: Any) -> int:
|
|
326
352
|
"""启动并阻塞(等价于 `cli run`),Ctrl+C 退出。返回进程退出码。"""
|
|
327
353
|
handle = start_proxy(config, **kwargs)
|
|
354
|
+
if not isinstance(handle, ProxyHandle):
|
|
355
|
+
raise ProxyStartError(
|
|
356
|
+
"run_forever 不支持 background=True;后台模式请直接调用 start_proxy(background=True)",
|
|
357
|
+
code="invalid_argument",
|
|
358
|
+
)
|
|
328
359
|
try:
|
|
329
360
|
handle.runtime.serve_forever(reload_watch=True)
|
|
330
361
|
except KeyboardInterrupt:
|
|
@@ -335,11 +366,18 @@ def run_forever(config: Config | str | os.PathLike[str] | None = None, **kwargs:
|
|
|
335
366
|
|
|
336
367
|
|
|
337
368
|
__all__ = [
|
|
369
|
+
"BackgroundProxy",
|
|
338
370
|
"ProxyHandle",
|
|
339
371
|
"ProxyStartError",
|
|
372
|
+
"StopResult",
|
|
373
|
+
"get_status",
|
|
340
374
|
"init_config",
|
|
375
|
+
"is_running",
|
|
341
376
|
"load_config",
|
|
342
377
|
"make_runtime",
|
|
378
|
+
"resolve_runtime_dir",
|
|
343
379
|
"run_forever",
|
|
344
380
|
"start_proxy",
|
|
381
|
+
"start_proxy_background",
|
|
382
|
+
"stop_proxy",
|
|
345
383
|
]
|
|
@@ -610,23 +610,25 @@ class WinDivertBackend(BaseBackend):
|
|
|
610
610
|
if direction is None: # pragma: no cover - start() 里已缓存
|
|
611
611
|
direction = pydivert.Direction.OUTBOUND if outbound else pydivert.Direction.INBOUND
|
|
612
612
|
|
|
613
|
-
|
|
613
|
+
# 没显式给就沿用原包的接口索引;生产路径两个方向都会显式传。
|
|
614
|
+
idx = int(self._ifidx(packet) if ifidx is None else ifidx)
|
|
615
|
+
|
|
616
|
+
# 元数据必须在构造期传入,不能事后改 wd_addr:
|
|
617
|
+
# pydivert 4.0.0 的 send() 默认会调用 recalculate_checksums(),而它在 Windows
|
|
618
|
+
# 上先执行 _populate_wd_addr(),按构造期缓存的 _loopback/_interface/_direction
|
|
619
|
+
# 重建 WINDIVERT_ADDRESS。直接写 out.wd_addr.Loopback/IfIdx 会被清零,
|
|
620
|
+
# 回环 SYN 被内核丢弃,应用卡在 SYN_SENT(3.x 不做这次重建,所以以前没暴露)。
|
|
621
|
+
out = pydivert.Packet(
|
|
622
|
+
raw,
|
|
623
|
+
interface=(idx, 0),
|
|
624
|
+
direction=direction,
|
|
625
|
+
loopback=outbound,
|
|
626
|
+
)
|
|
614
627
|
# 用改写后的报文挑句柄(地址族与原始包一致,但这里的数据更权威)
|
|
615
628
|
handle = self._handle_for(out) or handle
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
# 回环流量要置 Loopback 位:内核据此把它当本机回环报文处理
|
|
620
|
-
addr.Loopback = 1 if outbound else 0
|
|
621
|
-
addr.Sniffed = 0
|
|
622
|
-
addr.Impostor = 0
|
|
623
|
-
# 没显式给就沿用原包的接口索引(老行为,够用);生产路径两个方向都会显式传
|
|
624
|
-
idx = self._ifidx(packet) if ifidx is None else ifidx
|
|
625
|
-
if idx:
|
|
626
|
-
addr.Network.IfIdx = int(idx)
|
|
627
|
-
# 校验和交给 pydivert 重算兜底(wd_addr 上的 *Checksum 标志会影响 helper 的行为,
|
|
628
|
-
# 让它自己算最稳);我们 pkt.py 的校验和另有单测保证正确性。
|
|
629
|
-
handle.send(out)
|
|
629
|
+
# pkt.py 的 redirect/reflect 已经算好校验和;关掉 pydivert 的重算,
|
|
630
|
+
# 既避免重复计算,也避免 4.0.0 重建地址时依赖构造期缓存的隐式行为。
|
|
631
|
+
handle.send(out, recalculate_checksum=False)
|
|
630
632
|
|
|
631
633
|
# ------------------------------------------------------------ 统计 -----
|
|
632
634
|
def stats(self) -> dict[str, Any]:
|