dpe-conformance 0.4.0__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.
- dpe_conformance-0.4.0/.gitignore +19 -0
- dpe_conformance-0.4.0/LICENSE +21 -0
- dpe_conformance-0.4.0/PKG-INFO +121 -0
- dpe_conformance-0.4.0/README.md +101 -0
- dpe_conformance-0.4.0/pyproject.toml +49 -0
- dpe_conformance-0.4.0/src/dpe_conformance/__init__.py +16 -0
- dpe_conformance-0.4.0/src/dpe_conformance/__main__.py +10 -0
- dpe_conformance-0.4.0/src/dpe_conformance/_env_transport.py +64 -0
- dpe_conformance-0.4.0/src/dpe_conformance/_proxy.py +192 -0
- dpe_conformance-0.4.0/src/dpe_conformance/checks/__init__.py +5 -0
- dpe_conformance-0.4.0/src/dpe_conformance/checks/capabilities.py +94 -0
- dpe_conformance-0.4.0/src/dpe_conformance/checks/commit.py +309 -0
- dpe_conformance-0.4.0/src/dpe_conformance/checks/contracts.py +257 -0
- dpe_conformance-0.4.0/src/dpe_conformance/checks/convergence.py +225 -0
- dpe_conformance-0.4.0/src/dpe_conformance/checks/http_binding.py +459 -0
- dpe_conformance-0.4.0/src/dpe_conformance/checks/large.py +638 -0
- dpe_conformance-0.4.0/src/dpe_conformance/checks/order.py +741 -0
- dpe_conformance-0.4.0/src/dpe_conformance/checks/read.py +350 -0
- dpe_conformance-0.4.0/src/dpe_conformance/checks/security.py +395 -0
- dpe_conformance-0.4.0/src/dpe_conformance/checks/smoke.py +75 -0
- dpe_conformance-0.4.0/src/dpe_conformance/checks/staging.py +408 -0
- dpe_conformance-0.4.0/src/dpe_conformance/checks/validation.py +175 -0
- dpe_conformance-0.4.0/src/dpe_conformance/checks/vectors.py +146 -0
- dpe_conformance-0.4.0/src/dpe_conformance/checks/write_ops.py +395 -0
- dpe_conformance-0.4.0/src/dpe_conformance/cli.py +133 -0
- dpe_conformance-0.4.0/src/dpe_conformance/config.py +238 -0
- dpe_conformance-0.4.0/src/dpe_conformance/conformance.ini +3 -0
- dpe_conformance-0.4.0/src/dpe_conformance/hooks.py +169 -0
- dpe_conformance-0.4.0/src/dpe_conformance/plugin.py +368 -0
- dpe_conformance-0.4.0/src/dpe_conformance/py.typed +0 -0
- dpe_conformance-0.4.0/src/dpe_conformance/runner.py +111 -0
- dpe_conformance-0.4.0/src/dpe_conformance/support.py +592 -0
- dpe_conformance-0.4.0/src/dpe_conformance/vectors/invalid_objects.json +927 -0
- dpe_conformance-0.4.0/src/dpe_conformance/vectors/manifest.json +253 -0
- dpe_conformance-0.4.0/src/dpe_conformance/vectors/nesting_depth.json +1335 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TuringFocus Team
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dpe-conformance
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: DPE 服务端一致性跑分器:对任意 remote 黑盒运行协议检查,输出 JUnit XML 与执行/跳过计数
|
|
5
|
+
Project-URL: Homepage, https://github.com/A2C-SMCP/dpe-protocol
|
|
6
|
+
Project-URL: Documentation, https://doc.turingfocus.cn/dpe/
|
|
7
|
+
Project-URL: Repository, https://github.com/A2C-SMCP/dpe-protocol
|
|
8
|
+
Author-email: TuringFocus Team <dev@turingfocus.cn>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Requires-Dist: dpe-hash==0.4.0
|
|
17
|
+
Requires-Dist: httpx<1,>=0.27
|
|
18
|
+
Requires-Dist: pytest<10,>=8.3
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# dpe-conformance
|
|
22
|
+
|
|
23
|
+
[DPE](https://doc.turingfocus.cn/dpe/) **服务端一致性跑分器**:黑盒对任意实现了 [core.md](https://doc.turingfocus.cn/dpe/latest/spec/core/) + [HTTP 绑定](https://doc.turingfocus.cn/dpe/latest/spec/bindings/http/) 的 remote 运行协议检查(检查面见仓库 [`conformance/README.md`](https://github.com/A2C-SMCP/dpe-protocol/blob/main/conformance/README.md))。跑分器本体只依赖 `dpe-hash`(期望 hash)与 HTTP,不依赖任何服务端实现。
|
|
24
|
+
|
|
25
|
+
> **关于 0.4**:本包随 0.4.0 首次发布;同期规范与 SDK 的变化按读者汇总在 [0.4 升级须知](https://doc.turingfocus.cn/dpe/latest/docs/upgrade/v0.4/),下文各处「升级须知」只是摘要。
|
|
26
|
+
|
|
27
|
+
## 用法
|
|
28
|
+
|
|
29
|
+
```console
|
|
30
|
+
$ pip install dpe-conformance==X.Y.Z # X.Y.* 对应规范文档 X.Y.*
|
|
31
|
+
$ dpe-conformance https://host/r/1 --junit-xml report.xml --counts-json counts.json
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- `REMOTE-URL` 须符合 HTTP 绑定 §1 的 remote URL 文法(与 SDK 同一判定,`dpe_hash.check_remote_url`):不合法即以退出码 2 拒绝、不开跑,消息不回显 URL——SDK 连不上的 remote(如带查询串、尾部 `/`、大写 scheme)跑分全绿没有意义。
|
|
35
|
+
- **规范变化(#183)**:演练契约由 `dpe2` 改名为 `dpex`(契约 1 §1,#183),按旧文档同时声明 `dpe1` 与 `dpe2` 做演练的服务端须改为声明 `dpex`——否则契约升级期一章与以演练契约为用例契约的向量用例会被如实 skip(不报错),若 CI 没有守住执行数基线就察觉不到覆盖的丢失;
|
|
36
|
+
- `--section NAME` 按章过滤(可重复;缺省全部章节;未知章节以退出码 2 拒绝——拼错章节名不会静默跑出一个「假绿」的空套件);
|
|
37
|
+
- `--junit-xml PATH` 输出 JUnit XML(CI 直接消费);
|
|
38
|
+
- `--counts-json PATH` 输出机器可读计数,供 CI 断言**执行数不低于基线**(防止因 skip 过多而假绿):
|
|
39
|
+
|
|
40
|
+
结构如下(示意:数字为虚构,`sections` 只节选两章;实际每章一项,键为章节名):
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"schema": 1,
|
|
45
|
+
"target": "https://host/r/1",
|
|
46
|
+
"totals": {"executed": 40, "passed": 39, "failed": 1, "errors": 0, "skipped": 2, "collection_errors": 0},
|
|
47
|
+
"sections": {
|
|
48
|
+
"smoke": {"executed": 3, "passed": 3, "failed": 0, "errors": 0, "skipped": 0},
|
|
49
|
+
"staging": {"executed": 10, "passed": 9, "failed": 1, "errors": 0, "skipped": 2}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
章节名即 `src/dpe_conformance/checks/` 下的一级模块名;章节与检查条数随版本增加,本文不列具体数字——当前版本的章节列表见 `--section` 给出未知章节名时的报错,各章条数以实际输出的 `counts.json` 为准。CI 的执行数基线按你接入的身份与钩子自行记录。
|
|
55
|
+
|
|
56
|
+
在服务端仓库 CI 中的接入步骤与最小 GitHub Actions 示例见 [在 CI 中运行跑分器](https://github.com/A2C-SMCP/dpe-protocol/blob/main/conformance/ci.md)。
|
|
57
|
+
|
|
58
|
+
`executed` = 取得通过/失败结论的用例(即 `passed + failed`);`errors` 与 `collection_errors` 单列。退出码:`0` 全部通过(skip 不算失败),`1` 有失败,`2` 用法/配置/运行故障(含「没有任何检查被执行」)。
|
|
59
|
+
|
|
60
|
+
## 代理
|
|
61
|
+
|
|
62
|
+
跑分器**只从环境变量取代理**(`HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY`),从不读操作系统的代理设置(macOS / Windows 的系统代理、PAC 都不看)。规则与 Python / Rust SDK 相同、共用一张用例表(`sdk/testdata/proxy_env.json`),详见 `dpe-sdk` README 传输一节的「代理只来自环境变量」。要点:
|
|
63
|
+
|
|
64
|
+
- 按**每个请求的目的地**判定:被测服务端与 HTTP 钩子(可在另一个 origin)各自套用规则,例如钩子地址可以经 `NO_PROXY` 直连而 target 走代理;
|
|
65
|
+
- 大写变量优先,空值视为未设置;`NO_PROXY` 支持 `*`、域名及子域、IP 与 CIDR;
|
|
66
|
+
- 代理 URL 按固定文法校验(只支持 `http://` / `https://`,密码里的 `@` 写成 `%40`);不合法时在发出任何请求之前以退出码 `2` 结束,报错只含变量名、不回显值;
|
|
67
|
+
- 关闭代理:不设这些变量,或设 `NO_PROXY=*`。
|
|
68
|
+
|
|
69
|
+
## 配置
|
|
70
|
+
|
|
71
|
+
可选配置文件(TOML,封闭 schema,未知键一律拒绝),缺省在 CWD 下找 `dpe-conformance.toml`,或以 `--config` 指定。**凭证不进命令行与日志**,只放上 `Authorization` 请求头;身份建议经 `env` 引用环境变量,`authorization` 字面量仅限低权限/一次性测试凭证。
|
|
72
|
+
|
|
73
|
+
```toml
|
|
74
|
+
schema = 1
|
|
75
|
+
uri-prefix = "dpe://conformance/" # 测试 URI 命名空间(缺省值即此;每次运行另加一次性 token)
|
|
76
|
+
|
|
77
|
+
[identities.full] # 全量写权(core §8 安全检查的「有写授权」侧)
|
|
78
|
+
env = "DPE_CONF_FULL_AUTH"
|
|
79
|
+
[identities.scoped] # 仅前缀可写(服务端按该前缀授予写权;检查会在该前缀下新建文档)
|
|
80
|
+
authorization = "Bearer scoped"
|
|
81
|
+
prefix = "dpe://acme/scoped/"
|
|
82
|
+
[identities.no-force] # 无 force 权限(force 的 403 检查;需对测试
|
|
83
|
+
env = "DPE_CONF_NO_FORCE_AUTH" # 命名空间可写,否则该检查显式 skip 并计数)
|
|
84
|
+
|
|
85
|
+
[hooks.self-write] # 服务端自身写入(core §2.4 同级写入)
|
|
86
|
+
http = "http://127.0.0.1:8000/__hooks__/self-write"
|
|
87
|
+
[hooks.expire-session] # 会话立即过期(否则 410 检查要等 ≥ 1 小时);与 command 二选一
|
|
88
|
+
command = ["my-server", "expire-session"]
|
|
89
|
+
[hooks.clock-advance] # 推进服务端时钟 N 秒(会话 TTL 按推进后的时间判定)
|
|
90
|
+
http = "http://127.0.0.1:8000/__hooks__/clock/advance"
|
|
91
|
+
|
|
92
|
+
[session]
|
|
93
|
+
staging-ttl-seconds = 30 # 服务端以短 TTL 运行时的声明
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
身份与钩子都是可选的:**未配置时,依赖它们的检查显式 skip 并计入计数**(`--counts-json` 的 `skipped`),CI 以执行数基线拦截「skip 太多」的假绿。
|
|
97
|
+
|
|
98
|
+
配置 scoped 身份后,部分检查需要「scoped 有写权」的文档,会在它的可写前缀下(测试命名空间 `uri-prefix` 之外)新建文档,跑完不清理。请为 scoped 身份配置一个专供跑分的前缀。
|
|
99
|
+
|
|
100
|
+
另有两类 skip 取决于服务端本身,同样如实计数:认证顺序的检查先发一个不带凭证的探针,只有服务端对它返回 `401` 时才执行(接受匿名请求的服务端无从观测「认证先于报文校验」);一致性向量用例只在输入涉及的契约前缀于用例口径与服务端声明的 `hash_contracts` 中受支持与否一致时执行(见 [在 CI 中运行跑分器](https://github.com/A2C-SMCP/dpe-protocol/blob/main/conformance/ci.md))。
|
|
101
|
+
|
|
102
|
+
## 服务端钩子契约
|
|
103
|
+
|
|
104
|
+
钩子是测试侧信道,**不进入 DPE 协议面**;跑分器按「HTTP 或命令」的入口配置驱动,不假定服务端如何实现。载荷成员名全部是 DPE 协议词汇。
|
|
105
|
+
|
|
106
|
+
- **`self-write`**:载荷 = `file_uri` + `document`(可附 `pages` / `objects` / `force`,与 commit 请求体成员同名)与前置条件 `base_hash` 或 `if_absent`;
|
|
107
|
+
- **`expire-session`**:载荷 `{"session_id": "…"}`;成功响应体给 `{"expired": true|false}`;
|
|
108
|
+
- **`clock-advance`**:载荷 `{"advance_seconds": N}`(正整数)——把服务端时钟推进 N 秒,**会话 TTL 由此按推进后的时间判定**(用于「过期 = 最近一次成功操作 + TTL」与续期时刻的精确断言,跑分器不靠睡眠);成功响应体给 `{"now": "<RFC 3339>"}`;
|
|
109
|
+
- **`inject-error`**(#123):载荷 `{"code", "probe", "count"?, "retry_after"?}`——让带请求头
|
|
110
|
+
`DPE-Conformance-Probe: <probe>` 的请求接下来 `count` 次(缺省 1)直接得到该错误
|
|
111
|
+
(`code` 只接受 `DPE_RATE_LIMITED` / `DPE_UNAVAILABLE`);`retry_after` 缺省时由服务端给
|
|
112
|
+
缺省提示值 `1`(HTTP 绑定 §5)。成功响应体给 `{"armed": true, "count": N}`。探针值由跑分器
|
|
113
|
+
每次生成,服务端只拦截同一探针的请求:不绑定身份、不携带凭证,未带该头(或头值不同)的
|
|
114
|
+
请求不受影响;该头只在钩子启用时被识别;
|
|
115
|
+
- 成功判据:HTTP 入口 `POST` 得 2xx;命令入口从 stdin 收一行 JSON、退出码 0(stdout 若为 JSON 对象则作为响应体)。
|
|
116
|
+
|
|
117
|
+
## 章节与扩展
|
|
118
|
+
|
|
119
|
+
套件以 pytest 实现,一节一章:`src/dpe_conformance/checks/` 下每个一级模块或包是一章(`--section` 的粒度)。新增章节即新增一个 pytest 模块/包,用例通过 `dpe_conformance.plugin` 的夹具取得被测 remote、身份与钩子;需要而未配置的依赖用对应夹具使用例显式 skip。
|
|
120
|
+
|
|
121
|
+
自测对手是 [参考服务端](https://github.com/A2C-SMCP/dpe-protocol/tree/main/sdk/python/packages/dpe-sdk/src/dpe_sdk/testing)(`dpe_sdk.testing`,`python -m dpe_sdk.testing`),仅出现在本包测试里,跑分器本体不 import 它。
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# dpe-conformance
|
|
2
|
+
|
|
3
|
+
[DPE](https://doc.turingfocus.cn/dpe/) **服务端一致性跑分器**:黑盒对任意实现了 [core.md](https://doc.turingfocus.cn/dpe/latest/spec/core/) + [HTTP 绑定](https://doc.turingfocus.cn/dpe/latest/spec/bindings/http/) 的 remote 运行协议检查(检查面见仓库 [`conformance/README.md`](https://github.com/A2C-SMCP/dpe-protocol/blob/main/conformance/README.md))。跑分器本体只依赖 `dpe-hash`(期望 hash)与 HTTP,不依赖任何服务端实现。
|
|
4
|
+
|
|
5
|
+
> **关于 0.4**:本包随 0.4.0 首次发布;同期规范与 SDK 的变化按读者汇总在 [0.4 升级须知](https://doc.turingfocus.cn/dpe/latest/docs/upgrade/v0.4/),下文各处「升级须知」只是摘要。
|
|
6
|
+
|
|
7
|
+
## 用法
|
|
8
|
+
|
|
9
|
+
```console
|
|
10
|
+
$ pip install dpe-conformance==X.Y.Z # X.Y.* 对应规范文档 X.Y.*
|
|
11
|
+
$ dpe-conformance https://host/r/1 --junit-xml report.xml --counts-json counts.json
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
- `REMOTE-URL` 须符合 HTTP 绑定 §1 的 remote URL 文法(与 SDK 同一判定,`dpe_hash.check_remote_url`):不合法即以退出码 2 拒绝、不开跑,消息不回显 URL——SDK 连不上的 remote(如带查询串、尾部 `/`、大写 scheme)跑分全绿没有意义。
|
|
15
|
+
- **规范变化(#183)**:演练契约由 `dpe2` 改名为 `dpex`(契约 1 §1,#183),按旧文档同时声明 `dpe1` 与 `dpe2` 做演练的服务端须改为声明 `dpex`——否则契约升级期一章与以演练契约为用例契约的向量用例会被如实 skip(不报错),若 CI 没有守住执行数基线就察觉不到覆盖的丢失;
|
|
16
|
+
- `--section NAME` 按章过滤(可重复;缺省全部章节;未知章节以退出码 2 拒绝——拼错章节名不会静默跑出一个「假绿」的空套件);
|
|
17
|
+
- `--junit-xml PATH` 输出 JUnit XML(CI 直接消费);
|
|
18
|
+
- `--counts-json PATH` 输出机器可读计数,供 CI 断言**执行数不低于基线**(防止因 skip 过多而假绿):
|
|
19
|
+
|
|
20
|
+
结构如下(示意:数字为虚构,`sections` 只节选两章;实际每章一项,键为章节名):
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"schema": 1,
|
|
25
|
+
"target": "https://host/r/1",
|
|
26
|
+
"totals": {"executed": 40, "passed": 39, "failed": 1, "errors": 0, "skipped": 2, "collection_errors": 0},
|
|
27
|
+
"sections": {
|
|
28
|
+
"smoke": {"executed": 3, "passed": 3, "failed": 0, "errors": 0, "skipped": 0},
|
|
29
|
+
"staging": {"executed": 10, "passed": 9, "failed": 1, "errors": 0, "skipped": 2}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
章节名即 `src/dpe_conformance/checks/` 下的一级模块名;章节与检查条数随版本增加,本文不列具体数字——当前版本的章节列表见 `--section` 给出未知章节名时的报错,各章条数以实际输出的 `counts.json` 为准。CI 的执行数基线按你接入的身份与钩子自行记录。
|
|
35
|
+
|
|
36
|
+
在服务端仓库 CI 中的接入步骤与最小 GitHub Actions 示例见 [在 CI 中运行跑分器](https://github.com/A2C-SMCP/dpe-protocol/blob/main/conformance/ci.md)。
|
|
37
|
+
|
|
38
|
+
`executed` = 取得通过/失败结论的用例(即 `passed + failed`);`errors` 与 `collection_errors` 单列。退出码:`0` 全部通过(skip 不算失败),`1` 有失败,`2` 用法/配置/运行故障(含「没有任何检查被执行」)。
|
|
39
|
+
|
|
40
|
+
## 代理
|
|
41
|
+
|
|
42
|
+
跑分器**只从环境变量取代理**(`HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY`),从不读操作系统的代理设置(macOS / Windows 的系统代理、PAC 都不看)。规则与 Python / Rust SDK 相同、共用一张用例表(`sdk/testdata/proxy_env.json`),详见 `dpe-sdk` README 传输一节的「代理只来自环境变量」。要点:
|
|
43
|
+
|
|
44
|
+
- 按**每个请求的目的地**判定:被测服务端与 HTTP 钩子(可在另一个 origin)各自套用规则,例如钩子地址可以经 `NO_PROXY` 直连而 target 走代理;
|
|
45
|
+
- 大写变量优先,空值视为未设置;`NO_PROXY` 支持 `*`、域名及子域、IP 与 CIDR;
|
|
46
|
+
- 代理 URL 按固定文法校验(只支持 `http://` / `https://`,密码里的 `@` 写成 `%40`);不合法时在发出任何请求之前以退出码 `2` 结束,报错只含变量名、不回显值;
|
|
47
|
+
- 关闭代理:不设这些变量,或设 `NO_PROXY=*`。
|
|
48
|
+
|
|
49
|
+
## 配置
|
|
50
|
+
|
|
51
|
+
可选配置文件(TOML,封闭 schema,未知键一律拒绝),缺省在 CWD 下找 `dpe-conformance.toml`,或以 `--config` 指定。**凭证不进命令行与日志**,只放上 `Authorization` 请求头;身份建议经 `env` 引用环境变量,`authorization` 字面量仅限低权限/一次性测试凭证。
|
|
52
|
+
|
|
53
|
+
```toml
|
|
54
|
+
schema = 1
|
|
55
|
+
uri-prefix = "dpe://conformance/" # 测试 URI 命名空间(缺省值即此;每次运行另加一次性 token)
|
|
56
|
+
|
|
57
|
+
[identities.full] # 全量写权(core §8 安全检查的「有写授权」侧)
|
|
58
|
+
env = "DPE_CONF_FULL_AUTH"
|
|
59
|
+
[identities.scoped] # 仅前缀可写(服务端按该前缀授予写权;检查会在该前缀下新建文档)
|
|
60
|
+
authorization = "Bearer scoped"
|
|
61
|
+
prefix = "dpe://acme/scoped/"
|
|
62
|
+
[identities.no-force] # 无 force 权限(force 的 403 检查;需对测试
|
|
63
|
+
env = "DPE_CONF_NO_FORCE_AUTH" # 命名空间可写,否则该检查显式 skip 并计数)
|
|
64
|
+
|
|
65
|
+
[hooks.self-write] # 服务端自身写入(core §2.4 同级写入)
|
|
66
|
+
http = "http://127.0.0.1:8000/__hooks__/self-write"
|
|
67
|
+
[hooks.expire-session] # 会话立即过期(否则 410 检查要等 ≥ 1 小时);与 command 二选一
|
|
68
|
+
command = ["my-server", "expire-session"]
|
|
69
|
+
[hooks.clock-advance] # 推进服务端时钟 N 秒(会话 TTL 按推进后的时间判定)
|
|
70
|
+
http = "http://127.0.0.1:8000/__hooks__/clock/advance"
|
|
71
|
+
|
|
72
|
+
[session]
|
|
73
|
+
staging-ttl-seconds = 30 # 服务端以短 TTL 运行时的声明
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
身份与钩子都是可选的:**未配置时,依赖它们的检查显式 skip 并计入计数**(`--counts-json` 的 `skipped`),CI 以执行数基线拦截「skip 太多」的假绿。
|
|
77
|
+
|
|
78
|
+
配置 scoped 身份后,部分检查需要「scoped 有写权」的文档,会在它的可写前缀下(测试命名空间 `uri-prefix` 之外)新建文档,跑完不清理。请为 scoped 身份配置一个专供跑分的前缀。
|
|
79
|
+
|
|
80
|
+
另有两类 skip 取决于服务端本身,同样如实计数:认证顺序的检查先发一个不带凭证的探针,只有服务端对它返回 `401` 时才执行(接受匿名请求的服务端无从观测「认证先于报文校验」);一致性向量用例只在输入涉及的契约前缀于用例口径与服务端声明的 `hash_contracts` 中受支持与否一致时执行(见 [在 CI 中运行跑分器](https://github.com/A2C-SMCP/dpe-protocol/blob/main/conformance/ci.md))。
|
|
81
|
+
|
|
82
|
+
## 服务端钩子契约
|
|
83
|
+
|
|
84
|
+
钩子是测试侧信道,**不进入 DPE 协议面**;跑分器按「HTTP 或命令」的入口配置驱动,不假定服务端如何实现。载荷成员名全部是 DPE 协议词汇。
|
|
85
|
+
|
|
86
|
+
- **`self-write`**:载荷 = `file_uri` + `document`(可附 `pages` / `objects` / `force`,与 commit 请求体成员同名)与前置条件 `base_hash` 或 `if_absent`;
|
|
87
|
+
- **`expire-session`**:载荷 `{"session_id": "…"}`;成功响应体给 `{"expired": true|false}`;
|
|
88
|
+
- **`clock-advance`**:载荷 `{"advance_seconds": N}`(正整数)——把服务端时钟推进 N 秒,**会话 TTL 由此按推进后的时间判定**(用于「过期 = 最近一次成功操作 + TTL」与续期时刻的精确断言,跑分器不靠睡眠);成功响应体给 `{"now": "<RFC 3339>"}`;
|
|
89
|
+
- **`inject-error`**(#123):载荷 `{"code", "probe", "count"?, "retry_after"?}`——让带请求头
|
|
90
|
+
`DPE-Conformance-Probe: <probe>` 的请求接下来 `count` 次(缺省 1)直接得到该错误
|
|
91
|
+
(`code` 只接受 `DPE_RATE_LIMITED` / `DPE_UNAVAILABLE`);`retry_after` 缺省时由服务端给
|
|
92
|
+
缺省提示值 `1`(HTTP 绑定 §5)。成功响应体给 `{"armed": true, "count": N}`。探针值由跑分器
|
|
93
|
+
每次生成,服务端只拦截同一探针的请求:不绑定身份、不携带凭证,未带该头(或头值不同)的
|
|
94
|
+
请求不受影响;该头只在钩子启用时被识别;
|
|
95
|
+
- 成功判据:HTTP 入口 `POST` 得 2xx;命令入口从 stdin 收一行 JSON、退出码 0(stdout 若为 JSON 对象则作为响应体)。
|
|
96
|
+
|
|
97
|
+
## 章节与扩展
|
|
98
|
+
|
|
99
|
+
套件以 pytest 实现,一节一章:`src/dpe_conformance/checks/` 下每个一级模块或包是一章(`--section` 的粒度)。新增章节即新增一个 pytest 模块/包,用例通过 `dpe_conformance.plugin` 的夹具取得被测 remote、身份与钩子;需要而未配置的依赖用对应夹具使用例显式 skip。
|
|
100
|
+
|
|
101
|
+
自测对手是 [参考服务端](https://github.com/A2C-SMCP/dpe-protocol/tree/main/sdk/python/packages/dpe-sdk/src/dpe_sdk/testing)(`dpe_sdk.testing`,`python -m dpe_sdk.testing`),仅出现在本包测试里,跑分器本体不 import 它。
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dpe-conformance"
|
|
3
|
+
version = "0.4.0"
|
|
4
|
+
description = "DPE 服务端一致性跑分器:对任意 remote 黑盒运行协议检查,输出 JUnit XML 与执行/跳过计数"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
# 仓库根 LICENSE 的副本(构建后端只收录包目录内的文件)
|
|
9
|
+
license-files = ["LICENSE"]
|
|
10
|
+
authors = [
|
|
11
|
+
{name = "TuringFocus Team", email = "dev@turingfocus.cn"}
|
|
12
|
+
]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Typing :: Typed",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
# 期望 hash 只依赖 dpe-hash(#26):跑分器不依赖 dpe-sdk 与任何服务端实现
|
|
21
|
+
"dpe-hash==0.4.0",
|
|
22
|
+
"httpx>=0.27,<1",
|
|
23
|
+
# 套件以 pytest 实现(--section 过滤、skip 语义与 JUnit XML 直接复用其报告设施)。
|
|
24
|
+
# 计数归章依赖 Node.location 的 rootpath 相对语义(pytest 9 已核实),锁主版本上界;
|
|
25
|
+
# 升级 pytest 主版本时放开上界并重跑全套件。
|
|
26
|
+
"pytest>=8.3,<10",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
dpe-conformance = "dpe_conformance.cli:main"
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/A2C-SMCP/dpe-protocol"
|
|
34
|
+
Documentation = "https://doc.turingfocus.cn/dpe/"
|
|
35
|
+
Repository = "https://github.com/A2C-SMCP/dpe-protocol"
|
|
36
|
+
|
|
37
|
+
[tool.uv.sources]
|
|
38
|
+
dpe-hash = { workspace = true }
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["hatchling>=1.27"]
|
|
42
|
+
build-backend = "hatchling.build"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
packages = ["src/dpe_conformance"]
|
|
46
|
+
|
|
47
|
+
# 测试以 workspace 的 dpe-sdk 参考服务端为自测对手(#16),不随 sdist 分发
|
|
48
|
+
[tool.hatch.build.targets.sdist]
|
|
49
|
+
exclude = ["tests"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""dpe-conformance:DPE 服务端一致性跑分器。
|
|
2
|
+
|
|
3
|
+
黑盒对任意 remote 运行协议检查(检查面见仓库 `conformance/README.md`):套件以 pytest
|
|
4
|
+
实现、按章节组织在 ``dpe_conformance.checks`` 下;``dpe-conformance`` CLI 按中性配置
|
|
5
|
+
(身份与服务端钩子,见 ``dpe_conformance.config``)驱动套件,输出 JUnit XML 与「已执行 /
|
|
6
|
+
跳过」计数,供 CI 断言执行数不低于基线(防止因 skip 而假绿)。
|
|
7
|
+
|
|
8
|
+
跑分器本体只依赖 dpe-hash(期望 hash)与 HTTP;不 import 任何服务端实现——参考服务端
|
|
9
|
+
(``dpe_sdk.testing``)只在本包的测试里作为自测对手,以独立进程或 ASGI 方式连接。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from importlib.metadata import version
|
|
13
|
+
|
|
14
|
+
__version__ = version("dpe-conformance")
|
|
15
|
+
|
|
16
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""按目的地套用代理规则的 httpx 传输层(#194):跑分器不读操作系统的代理设置。
|
|
2
|
+
|
|
3
|
+
httpx 缺省(``trust_env=True``)在 macOS / Windows 上环境变量为空时会回退读系统代理,对被测
|
|
4
|
+
服务端(含本机回环地址)的请求可能被悄悄送进系统代理。给客户端显式传入本传输层后,httpx 不再
|
|
5
|
+
自行探测代理;是否经代理、经哪个,由 ``dpe_conformance._proxy`` 的规则按**每个请求的目的地**
|
|
6
|
+
判定——target 与服务端钩子(``hooks``,可在另一个 origin)共用一个客户端,不能只按 target 判定
|
|
7
|
+
一次。环境变量在构造时快照一次。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from dpe_conformance._proxy import proxy_for
|
|
18
|
+
|
|
19
|
+
__all__ = ["EnvProxyTransport", "destination"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def destination(url: httpx.URL | str) -> str:
|
|
23
|
+
"""规则按 scheme 与主机判定:把一个 URL 化成 ``scheme://主机/``(IPv6 带方括号)。
|
|
24
|
+
|
|
25
|
+
主机取线上的 ASCII 形式(``raw_host``):``host`` 会把 ``xn--`` 主机解码成 Unicode,与 SDK
|
|
26
|
+
按原串字符切分的口径不同(``NO_PROXY`` 的 ``xn--`` 条目会因此匹配不上)。
|
|
27
|
+
"""
|
|
28
|
+
parsed = httpx.URL(url)
|
|
29
|
+
host = parsed.raw_host.decode("ascii")
|
|
30
|
+
if ":" in host:
|
|
31
|
+
host = f"[{host}]"
|
|
32
|
+
return f"{parsed.scheme}://{host}/"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class EnvProxyTransport(httpx.BaseTransport):
|
|
36
|
+
"""按目的地选择直连或经代理的 ``httpx.HTTPTransport``(同一代理复用同一个传输层)。
|
|
37
|
+
|
|
38
|
+
代理 URL 不合法时 ``proxy_for`` 抛 ``ValueError``;跑分器在配置阶段已对 target 与每个 HTTP
|
|
39
|
+
钩子预先判定过(见 ``plugin``),请求期不会遇到。
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, environ: Mapping[str, str]) -> None:
|
|
43
|
+
self._environ = dict(environ)
|
|
44
|
+
self._transports: dict[str | None, httpx.HTTPTransport] = {}
|
|
45
|
+
self._lock = threading.Lock() # 并发请求不重复创建(泄漏)同一代理的连接池
|
|
46
|
+
|
|
47
|
+
def proxy_for(self, url: httpx.URL | str) -> str | None:
|
|
48
|
+
"""该目的地应经的代理 URL;直连为 ``None``。"""
|
|
49
|
+
return proxy_for(destination(url), self._environ)
|
|
50
|
+
|
|
51
|
+
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
|
52
|
+
proxy = self.proxy_for(request.url)
|
|
53
|
+
with self._lock:
|
|
54
|
+
transport = self._transports.get(proxy)
|
|
55
|
+
if transport is None:
|
|
56
|
+
transport = self._transports[proxy] = httpx.HTTPTransport(proxy=proxy)
|
|
57
|
+
return transport.handle_request(request)
|
|
58
|
+
|
|
59
|
+
def close(self) -> None:
|
|
60
|
+
with self._lock:
|
|
61
|
+
transports = list(self._transports.values())
|
|
62
|
+
self._transports.clear()
|
|
63
|
+
for transport in transports:
|
|
64
|
+
transport.close()
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""代理只来自环境变量(#194):判定规则的**自留副本**,原件是 ``dpe_sdk._proxy``。
|
|
2
|
+
|
|
3
|
+
跑分器不依赖 dpe-sdk(#26 的边界),代理判定又不是规范定义的契约、不进 dpe-hash(#194 架构
|
|
4
|
+
裁决),所以在这里保留一份。本文件除模块说明外与原件**逐字相同**:测试比较两者的 AST,并逐例
|
|
5
|
+
消费两个 SDK 共用的用例表 ``sdk/testdata/proxy_env.json``——改规则须先改原件、再同步这里。
|
|
6
|
+
|
|
7
|
+
规则(详见原件的模块说明):只读 ``HTTP_PROXY`` / ``HTTPS_PROXY`` / ``ALL_PROXY`` /
|
|
8
|
+
``NO_PROXY``,从不读操作系统的代理设置;大写优先、ASCII 空白去掉后为空视为未设置;存在
|
|
9
|
+
``REQUEST_METHOD`` 时全部忽略;``NO_PROXY`` 支持 ``*``、域名及子域、IP 与 CIDR;代理 URL 按
|
|
10
|
+
固定文法校验,不合法抛 ``ValueError``(消息不回显值)。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ipaddress
|
|
16
|
+
import re
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
|
|
19
|
+
__all__ = ["proxy_for"]
|
|
20
|
+
|
|
21
|
+
_Address = ipaddress.IPv4Address | ipaddress.IPv6Address
|
|
22
|
+
_Network = ipaddress.IPv4Network | ipaddress.IPv6Network
|
|
23
|
+
|
|
24
|
+
#: 两个 SDK 共用的空白字符集(Python 的 ``str.strip()`` 还会去掉 ``\x1c``–``\x1f`` 等,Rust 的
|
|
25
|
+
#: ``trim()`` 按 Unicode,二者都不用)
|
|
26
|
+
_WHITESPACE = " \t\n\r\x0b\x0c"
|
|
27
|
+
_ASCII_LOWER = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")
|
|
28
|
+
_SCHEME = re.compile(r"[A-Za-z][A-Za-z0-9+.\-]*", re.ASCII)
|
|
29
|
+
_LABEL = re.compile(r"[A-Za-z0-9\-]+", re.ASCII)
|
|
30
|
+
_PORT = re.compile(r"[0-9]{1,5}", re.ASCII)
|
|
31
|
+
_NUMERIC_LABEL = re.compile(r"[0-9]+|0[xX].*", re.ASCII)
|
|
32
|
+
#: 代理 URL 的长度上限(字符数,此时都是 ASCII):reqwest 连接时经 ``http::Uri`` 重新解析,超过
|
|
33
|
+
#: 其 65534 字节上限会静默直连——两侧都在构造时拒绝过长的值
|
|
34
|
+
_MAX_PROXY_URL = 4096
|
|
35
|
+
#: RFC 3986:URI 允许的字符(unreserved、gen-delims、sub-delims)与 pct-encoded
|
|
36
|
+
_URI = re.compile(r"(?:[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=]|%[0-9A-Fa-f]{2})*", re.ASCII)
|
|
37
|
+
#: RFC 3986 userinfo:unreserved、pct-encoded、sub-delims、``:``(没有 ``@``)
|
|
38
|
+
_USERINFO = re.compile(r"(?:[A-Za-z0-9\-._~!$&'()*+,;=:]|%[0-9A-Fa-f]{2})*", re.ASCII)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _lower(text: str) -> str:
|
|
42
|
+
return text.translate(_ASCII_LOWER)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _lookup(environ: Mapping[str, str], name: str) -> tuple[str, str] | None:
|
|
46
|
+
"""先大写、后小写,取第一个非空值:返回(变量名, 去掉首尾 ASCII 空白的值)。"""
|
|
47
|
+
for key in (name.upper(), name.lower()):
|
|
48
|
+
value = environ.get(key, "").strip(_WHITESPACE)
|
|
49
|
+
if value:
|
|
50
|
+
return key, value
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _remote_scheme_and_host(remote: str) -> tuple[str, str]:
|
|
55
|
+
"""已按 HTTP 绑定 §1 校验过的 remote 的 scheme 与主机(IPv6 去掉方括号):纯字符切分。"""
|
|
56
|
+
scheme, _, rest = remote.partition("://")
|
|
57
|
+
authority = rest.split("/", 1)[0]
|
|
58
|
+
if authority.startswith("["):
|
|
59
|
+
host = authority[1 : authority.find("]")]
|
|
60
|
+
else:
|
|
61
|
+
host = authority.partition(":")[0]
|
|
62
|
+
return _lower(scheme), _lower(host)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _address(text: str) -> _Address | None:
|
|
66
|
+
if text.startswith("[") and text.endswith("]"):
|
|
67
|
+
text = text[1:-1]
|
|
68
|
+
if "%" in text: # 带 zone 的 IPv6 不按地址处理(两个 SDK 同一口径)
|
|
69
|
+
return None
|
|
70
|
+
try:
|
|
71
|
+
return ipaddress.ip_address(text)
|
|
72
|
+
except ValueError:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _network(entry: str) -> _Network | None:
|
|
77
|
+
"""IP 或 ``addr/前缀长度``(前缀只接受十进制数字,不接受掩码写法)。"""
|
|
78
|
+
text, slash, prefix = entry.partition("/")
|
|
79
|
+
address = _address(text)
|
|
80
|
+
if address is None:
|
|
81
|
+
return None
|
|
82
|
+
if not slash:
|
|
83
|
+
return ipaddress.ip_network(address)
|
|
84
|
+
if not prefix.isascii() or not prefix.isdigit():
|
|
85
|
+
return None
|
|
86
|
+
# 先去掉前导零再看位数:去零后超过 3 位必然大于 128,直接判不匹配——不把任意长的数字串交给
|
|
87
|
+
# int()(Python 对超过 4300 位的转换直接抛错,#206)。与 Rust 按 u32 解析的结论逐例一致:
|
|
88
|
+
# 前导零照常接受,越界(含 u32 溢出)都不匹配
|
|
89
|
+
digits = prefix.lstrip("0") or "0"
|
|
90
|
+
if len(digits) > 3 or int(digits) > address.max_prefixlen:
|
|
91
|
+
return None
|
|
92
|
+
return ipaddress.ip_network((address, int(digits)), strict=False)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _bypass(host: str, no_proxy: str) -> bool:
|
|
96
|
+
"""remote 的主机是否命中 ``NO_PROXY``。"""
|
|
97
|
+
host_address = _address(host)
|
|
98
|
+
for raw in no_proxy.split(","):
|
|
99
|
+
entry = _lower(raw.strip(_WHITESPACE))
|
|
100
|
+
if not entry:
|
|
101
|
+
continue
|
|
102
|
+
if entry == "*":
|
|
103
|
+
return True
|
|
104
|
+
network = _network(entry)
|
|
105
|
+
if network is not None:
|
|
106
|
+
if host_address is not None and host_address in network:
|
|
107
|
+
return True
|
|
108
|
+
continue
|
|
109
|
+
domain = entry.removeprefix(".")
|
|
110
|
+
if domain and host_address is None and (host == domain or host.endswith("." + domain)):
|
|
111
|
+
return True
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _valid_host(host: str) -> bool:
|
|
116
|
+
"""代理主机:方括号内的 IPv6、点分十进制 IPv4,或 LDH 标签组成的域名(见模块说明)。"""
|
|
117
|
+
if host.startswith("["):
|
|
118
|
+
inner = host[1:-1] if host.endswith("]") else ""
|
|
119
|
+
if not inner or "%" in inner:
|
|
120
|
+
return False
|
|
121
|
+
try:
|
|
122
|
+
ipaddress.IPv6Address(inner)
|
|
123
|
+
except ValueError:
|
|
124
|
+
return False
|
|
125
|
+
return True
|
|
126
|
+
labels = host.split(".")
|
|
127
|
+
if not all(_LABEL.fullmatch(label) for label in labels):
|
|
128
|
+
return False # 含空标签(空主机、尾点、连续点)与 LDH 之外的字符
|
|
129
|
+
if any(_lower(label).startswith("xn--") for label in labels):
|
|
130
|
+
return False
|
|
131
|
+
if _NUMERIC_LABEL.fullmatch(labels[-1]):
|
|
132
|
+
try:
|
|
133
|
+
ipaddress.IPv4Address(host)
|
|
134
|
+
except ValueError:
|
|
135
|
+
return False
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _check_proxy_url(variable: str, url: str) -> tuple[str, int | None]:
|
|
140
|
+
"""按模块说明第 5 条校验补全 scheme 之后的代理 URL,返回(主机, 端口)(IPv6 带方括号,
|
|
141
|
+
端口没写为 ``None``);不合法抛 ``ValueError``。"""
|
|
142
|
+
invalid = ValueError(f"环境变量 {variable} 的代理 URL 不合法")
|
|
143
|
+
if len(url) > _MAX_PROXY_URL or not _URI.fullmatch(url):
|
|
144
|
+
raise invalid
|
|
145
|
+
scheme, _, rest = url.partition("://")
|
|
146
|
+
if not _SCHEME.fullmatch(scheme):
|
|
147
|
+
raise invalid # 不回显:不合法的「scheme」里可能夹着凭证
|
|
148
|
+
if _lower(scheme) not in ("http", "https"):
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"环境变量 {variable} 的代理 scheme {scheme!r} 不受支持(只支持 http / https)"
|
|
151
|
+
)
|
|
152
|
+
end = min((i for i in (rest.find(c) for c in "/?#") if i >= 0), default=len(rest))
|
|
153
|
+
authority, tail = rest[:end], rest[end:]
|
|
154
|
+
if tail not in ("", "/"):
|
|
155
|
+
raise invalid
|
|
156
|
+
userinfo, at, hostport = authority.rpartition("@")
|
|
157
|
+
if at and not _USERINFO.fullmatch(userinfo):
|
|
158
|
+
raise invalid
|
|
159
|
+
if hostport.startswith("["):
|
|
160
|
+
close = hostport.find("]")
|
|
161
|
+
host, port_part = (hostport[: close + 1], hostport[close + 1 :]) if close >= 0 else ("", "")
|
|
162
|
+
else:
|
|
163
|
+
host, colon, port = hostport.partition(":")
|
|
164
|
+
port_part = colon + port
|
|
165
|
+
if not _valid_host(host):
|
|
166
|
+
raise invalid
|
|
167
|
+
if not port_part:
|
|
168
|
+
return host, None
|
|
169
|
+
port = port_part[1:]
|
|
170
|
+
if not port_part.startswith(":") or not _PORT.fullmatch(port) or int(port) > 65535:
|
|
171
|
+
raise invalid
|
|
172
|
+
return host, int(port)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def proxy_for(remote: str, environ: Mapping[str, str]) -> str | None:
|
|
176
|
+
"""按模块说明的规则判定 ``remote`` 应经的代理 URL;直连返回 ``None``。
|
|
177
|
+
|
|
178
|
+
代理 URL 不合法或 scheme 不受支持时抛 ``ValueError``(消息不回显可能带凭证的值)。
|
|
179
|
+
"""
|
|
180
|
+
if "REQUEST_METHOD" in environ:
|
|
181
|
+
return None
|
|
182
|
+
scheme, host = _remote_scheme_and_host(remote)
|
|
183
|
+
found = _lookup(environ, f"{scheme}_proxy") or _lookup(environ, "all_proxy")
|
|
184
|
+
if found is None:
|
|
185
|
+
return None
|
|
186
|
+
no_proxy = _lookup(environ, "no_proxy")
|
|
187
|
+
if no_proxy is not None and _bypass(host, no_proxy[1]):
|
|
188
|
+
return None
|
|
189
|
+
variable, value = found
|
|
190
|
+
url = value if "://" in value else "http://" + value
|
|
191
|
+
_check_proxy_url(variable, url)
|
|
192
|
+
return url
|