dpe-conformance 0.4.0__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.
@@ -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,10 @@
1
+ """``python -m dpe_conformance``:等价于 ``dpe-conformance`` 控制台脚本。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from dpe_conformance.cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
@@ -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
@@ -0,0 +1,5 @@
1
+ """检查套件:一节一章(``--section`` 过滤的粒度),模块或包。
2
+
3
+ 每章是普通 pytest 模块:用例经 ``dpe_conformance.plugin`` 的夹具取得被测 remote、已配置
4
+ 身份与服务端钩子;需要而未配置的依赖使对应用例显式 skip(计入计数,见包 README)。
5
+ """
@@ -0,0 +1,94 @@
1
+ """章:capabilities 与契约头(#27 子集 1)。
2
+
3
+ - HTTP §4.1:capabilities 的字段与约束(``staging_ttl_seconds`` ≥ 3600 见 HTTP §4.1 与 CORE §3.4);
4
+ - HTTP §3.1:``DPE-Hash-Contract`` 是除 ``GET capabilities`` 外每个请求的必带头,
5
+ 缺失或不受支持 → ``DPE_CONTRACT_UNSUPPORTED``;无体端点的校验顺序为「契约 → uri」(HTTP §3.1)。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dpe_conformance.plugin import Target
11
+ from dpe_conformance.support import (
12
+ assert_head_error,
13
+ assert_problem,
14
+ has_dpe_vary,
15
+ optional_identity,
16
+ )
17
+
18
+ _LIMIT_FIELDS = (
19
+ "max_payload_bytes",
20
+ "page_max_bytes",
21
+ "staging_ttl_seconds",
22
+ "blob_max_bytes",
23
+ "blob_chunk_bytes",
24
+ "batch_head_max",
25
+ "list_page_max",
26
+ )
27
+
28
+
29
+ def test_capabilities_fields(conformance_target: Target) -> None:
30
+ """HTTP §4.1:capabilities 的响应体字段与约束;该端点不带
31
+ ``Vary: DPE-Hash-Contract``(HTTP §3.1)。"""
32
+ response = conformance_target.send(
33
+ "GET", "capabilities", contract=None, identity=optional_identity(conformance_target)
34
+ )
35
+ assert response.status_code == 200, f"capabilities 返回 {response.status_code}"
36
+ body = response.json()
37
+ assert body.get("protocol") == "dpe/1", f"protocol 是 {body.get('protocol')!r}"
38
+ contracts = body.get("hash_contracts")
39
+ assert (
40
+ isinstance(contracts, list) and contracts and all(isinstance(c, str) for c in contracts)
41
+ ), f"hash_contracts 必须是非空字符串数组:{contracts!r}"
42
+ assert "dpe1" in contracts, "跑分器以 dpe1 校验,服务端必须声明它"
43
+ limits = body.get("limits")
44
+ assert isinstance(limits, dict), f"limits 必须是对象:{limits!r}"
45
+ for field in _LIMIT_FIELDS:
46
+ value = limits.get(field)
47
+ assert isinstance(value, int) and not isinstance(value, bool), (
48
+ f"limits.{field} 必须是整数:{value!r}"
49
+ )
50
+ assert limits["staging_ttl_seconds"] >= 3600, (
51
+ f"staging_ttl_seconds 必须 ≥ 3600,实际 {limits['staging_ttl_seconds']}"
52
+ )
53
+ for field in ("content_encodings", "features"):
54
+ value = body.get(field)
55
+ assert isinstance(value, list) and all(isinstance(item, str) for item in value), (
56
+ f"{field} 必须是字符串数组:{value!r}"
57
+ )
58
+ assert not has_dpe_vary(response), "capabilities 不带 Vary: DPE-Hash-Contract(HTTP §3.1)"
59
+
60
+
61
+ def test_contract_header_missing_is_contract_unsupported(conformance_target: Target) -> None:
62
+ """HTTP §3.1:无契约头的 head → 400 + DPE_CONTRACT_UNSUPPORTED(HEAD 无体、只有错误码头)。"""
63
+ response = conformance_target.send(
64
+ "HEAD",
65
+ "documents",
66
+ contract=None,
67
+ identity=optional_identity(conformance_target),
68
+ params={"uri": conformance_target.uri("caps/absent")},
69
+ )
70
+ assert_head_error(response, status=400, code="DPE_CONTRACT_UNSUPPORTED")
71
+
72
+
73
+ def test_contract_header_unsupported_is_contract_unsupported(conformance_target: Target) -> None:
74
+ """HTTP §3.1:声明不受支持的契约 → 400 + DPE_CONTRACT_UNSUPPORTED。"""
75
+ response = conformance_target.send(
76
+ "HEAD",
77
+ "documents",
78
+ contract="dpe9",
79
+ identity=optional_identity(conformance_target),
80
+ params={"uri": conformance_target.uri("caps/absent")},
81
+ )
82
+ assert_head_error(response, status=400, code="DPE_CONTRACT_UNSUPPORTED")
83
+
84
+
85
+ def test_contract_header_missing_on_json_body_endpoint(conformance_target: Target) -> None:
86
+ """HTTP §4.2 + §3.1:带 JSON 体的端点缺契约头 → 400 + DPE_CONTRACT_UNSUPPORTED。"""
87
+ response = conformance_target.send(
88
+ "POST",
89
+ "heads",
90
+ contract=None,
91
+ identity=optional_identity(conformance_target),
92
+ json={"uris": [conformance_target.uri("caps/absent")]},
93
+ )
94
+ assert_problem(response, status=400, code="DPE_CONTRACT_UNSUPPORTED", documents_path=False)