store-grpc-py 0.1.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.
- store_grpc_py-0.1.0/PKG-INFO +19 -0
- store_grpc_py-0.1.0/README.md +3 -0
- store_grpc_py-0.1.0/pyproject.toml +23 -0
- store_grpc_py-0.1.0/setup.cfg +4 -0
- store_grpc_py-0.1.0/src/store_grpc/__init__.py +28 -0
- store_grpc_py-0.1.0/src/store_grpc/errors.py +105 -0
- store_grpc_py-0.1.0/src/store_grpc/proto.py +112 -0
- store_grpc_py-0.1.0/src/store_grpc/server.py +299 -0
- store_grpc_py-0.1.0/src/store_grpc_py.egg-info/PKG-INFO +19 -0
- store_grpc_py-0.1.0/src/store_grpc_py.egg-info/SOURCES.txt +13 -0
- store_grpc_py-0.1.0/src/store_grpc_py.egg-info/dependency_links.txt +1 -0
- store_grpc_py-0.1.0/src/store_grpc_py.egg-info/requires.txt +9 -0
- store_grpc_py-0.1.0/src/store_grpc_py.egg-info/top_level.txt +1 -0
- store_grpc_py-0.1.0/tests/test_conformance.py +156 -0
- store_grpc_py-0.1.0/tests/test_smoke.py +354 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: store-grpc-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: gRPC adapter that auto-generates a .proto service per schema for py-store (schema-driven CRUD over GQL, proto-first with runtime compilation, zero manual protoc)
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: py-store,storepy,grpc,protobuf,crud,gql
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Provides-Extra: grpc
|
|
10
|
+
Requires-Dist: grpcio>=1.60; extra == "grpc"
|
|
11
|
+
Requires-Dist: grpcio-tools>=1.60; extra == "grpc"
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
14
|
+
Requires-Dist: grpcio>=1.60; extra == "dev"
|
|
15
|
+
Requires-Dist: grpcio-tools>=1.60; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# store-grpc-py
|
|
18
|
+
|
|
19
|
+
py-store 的 gRPC 皮肤(`store-grpc-py`,PyPI)。规范与用法见仓库根 README 与 `../spec/`。
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "store-grpc-py"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "gRPC adapter that auto-generates a .proto service per schema for py-store (schema-driven CRUD over GQL, proto-first with runtime compilation, zero manual protoc)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
keywords = ["py-store", "storepy", "grpc", "protobuf", "crud", "gql"]
|
|
9
|
+
dependencies = []
|
|
10
|
+
|
|
11
|
+
[project.optional-dependencies]
|
|
12
|
+
grpc = ["grpcio>=1.60", "grpcio-tools>=1.60"]
|
|
13
|
+
dev = ["pytest>=8", "grpcio>=1.60", "grpcio-tools>=1.60"]
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["setuptools>=68"]
|
|
17
|
+
build-backend = "setuptools.build_meta"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
where = ["src"]
|
|
21
|
+
|
|
22
|
+
[tool.pytest.ini_options]
|
|
23
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""store_grpc_py — 为 py-store 已注册 schema 自动生成 gRPC service。
|
|
2
|
+
|
|
3
|
+
语义依据:../../spec/*.md(双端 parity,改动先改 spec)。
|
|
4
|
+
设计哲学:gRPC 只是 GQL 的又一层 RPC 皮肤 —— 适配层零语义发明。
|
|
5
|
+
proto-first 双出口:
|
|
6
|
+
build_proto(store, **opts) → .proto 文本(IDL 事实源,纯构建,零 gRPC 依赖)
|
|
7
|
+
export_proto(store, **opts) → 同上(命名对齐 store-graphql 的 export_sdl,喂外部 protoc / codegen)
|
|
8
|
+
create_server(store, **opts) → grpcio server(同一份 proto 文本运行时编译注册,零手工 protoc)
|
|
9
|
+
|
|
10
|
+
store 端口契约见 spec/00:list/get/query/query_one/insert/update/remove/set_context。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .proto import build_proto, export_proto, filter_archived, service_name
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"build_proto",
|
|
17
|
+
"export_proto",
|
|
18
|
+
"filter_archived",
|
|
19
|
+
"service_name",
|
|
20
|
+
"create_server",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create_server(*args, **kwargs):
|
|
25
|
+
"""延迟导入承载层(grpcio + grpcio-tools 为可选依赖)。"""
|
|
26
|
+
from .server import create_server as impl
|
|
27
|
+
|
|
28
|
+
return impl(*args, **kwargs)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""spec/03 — 错误判定链(判定顺序双端一致,改动必须先改 spec)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from grpc import StatusCode
|
|
8
|
+
|
|
9
|
+
ERR_GQL_PARSE_PREFIX = "ERR_GQL_PARSE:"
|
|
10
|
+
ERR_PERMISSION_PREFIX = "ERR_PERMISSION:"
|
|
11
|
+
|
|
12
|
+
GUARD_CODES_INVALID = ("INVALID_PARAM", "INVALID_BODY")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class MappedError(Exception):
|
|
17
|
+
"""已映射的 gRPC 错误:status / details / 稳定码(trailer store-error-code)。"""
|
|
18
|
+
|
|
19
|
+
status: StatusCode
|
|
20
|
+
details: str
|
|
21
|
+
code: str
|
|
22
|
+
|
|
23
|
+
def __str__(self) -> str: # abort 需要 message 可读
|
|
24
|
+
return self.details or self.code
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def store_code(err: BaseException) -> str:
|
|
28
|
+
"""store 错误的分类码:优先 err.code,缺省用类型名,再缺省通用码(与 REST storeCode 链一致)。"""
|
|
29
|
+
return getattr(err, "code", None) or type(err).__name__ or "STORE_ERROR"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _GuardError(Exception):
|
|
33
|
+
def __init__(self, code: str, message: str):
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.code = code
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def invalid_param(message: str) -> _GuardError:
|
|
39
|
+
return _GuardError("INVALID_PARAM", message)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def invalid_body(message: str) -> _GuardError:
|
|
43
|
+
return _GuardError("INVALID_BODY", message)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def not_found(message: str) -> _GuardError:
|
|
47
|
+
return _GuardError("NOT_FOUND", message)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ContextError(_GuardError):
|
|
51
|
+
def __init__(self, message: str):
|
|
52
|
+
super().__init__("CONTEXT_ERROR", message)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_json_field(raw: str | None, make_err, label: str):
|
|
56
|
+
"""spec/02:空串/None ⇒ None;非法 JSON ⇒ 带码错误。"""
|
|
57
|
+
if raw is None or raw == "":
|
|
58
|
+
return None
|
|
59
|
+
import json
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
return json.loads(raw)
|
|
63
|
+
except json.JSONDecodeError as e:
|
|
64
|
+
raise make_err(f"{label} 不是合法 JSON: {e}") from e
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def require_json_object(raw: str | None, label: str):
|
|
68
|
+
"""spec/02:必须为 JSON 对象编码(数组/标量/非法 JSON 拒绝);空 ⇒ None。"""
|
|
69
|
+
import json
|
|
70
|
+
|
|
71
|
+
v = parse_json_field(raw, invalid_body, label)
|
|
72
|
+
if v is not None and not isinstance(v, dict):
|
|
73
|
+
raise invalid_body(f"{label} 必须是 JSON 对象编码")
|
|
74
|
+
return v
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def map_store_error(err: BaseException, permission_error: type[BaseException] | None) -> MappedError:
|
|
78
|
+
"""spec/03 判定链:适配层守卫 → PermissionError 类 → ERR_PERMISSION: 前缀 → ERR_GQL_PARSE: 前缀 → INTERNAL。
|
|
79
|
+
|
|
80
|
+
details 一律原 message 透传、取不到置空串(禁伪造)。
|
|
81
|
+
"""
|
|
82
|
+
message = str(getattr(err, "args", [None])[0]) if getattr(err, "args", None) else ""
|
|
83
|
+
if message is None:
|
|
84
|
+
message = ""
|
|
85
|
+
|
|
86
|
+
def fail(status: StatusCode, code: str, details: str | None) -> MappedError:
|
|
87
|
+
return MappedError(status, details if details is not None else "", code)
|
|
88
|
+
|
|
89
|
+
if isinstance(err, _GuardError):
|
|
90
|
+
if err.code == "NOT_FOUND":
|
|
91
|
+
return fail(StatusCode.NOT_FOUND, err.code, err.args[0])
|
|
92
|
+
if err.code == "CONTEXT_ERROR":
|
|
93
|
+
return fail(StatusCode.UNAUTHENTICATED, err.code, err.args[0])
|
|
94
|
+
return fail(StatusCode.INVALID_ARGUMENT, err.code, err.args[0])
|
|
95
|
+
|
|
96
|
+
if permission_error is not None and isinstance(err, permission_error):
|
|
97
|
+
return fail(StatusCode.PERMISSION_DENIED, store_code(err), message)
|
|
98
|
+
|
|
99
|
+
if message.startswith(ERR_PERMISSION_PREFIX):
|
|
100
|
+
return fail(StatusCode.PERMISSION_DENIED, "ERR_PERMISSION", message)
|
|
101
|
+
|
|
102
|
+
if message.startswith(ERR_GQL_PARSE_PREFIX):
|
|
103
|
+
return fail(StatusCode.INVALID_ARGUMENT, "GQL_PARSE", message[len(ERR_GQL_PARSE_PREFIX):])
|
|
104
|
+
|
|
105
|
+
return fail(StatusCode.INTERNAL, store_code(err), message)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""spec/01 — proto 文本生成(与 node 端 buildProto 逐字一致,双端 byte-identical)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
ARCHIVE_SUFFIX = "Deleted"
|
|
8
|
+
PACKAGE = "store.v0"
|
|
9
|
+
|
|
10
|
+
_COMMON_MESSAGES = """message ListRequest {
|
|
11
|
+
string q = 1; // 模型名之后的 GQL 余部(参数列表与投影,REST ?q= 同源);空串 ⇒ 服务端补 schema 投影
|
|
12
|
+
string params_json = 2; // GQL params(如 {"c0":{"age":{"$gte":18}}})的 JSON 编码
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
message GetRequest { string id = 1; }
|
|
16
|
+
|
|
17
|
+
message CreateRequest { string body_json = 1; }
|
|
18
|
+
|
|
19
|
+
message UpdateRequest { string id = 1; string set_json = 2; }
|
|
20
|
+
|
|
21
|
+
message DeleteRequest { string id = 1; }
|
|
22
|
+
|
|
23
|
+
message StoreReply { string data_json = 1; }
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
_RPC_COMMENTS = {
|
|
27
|
+
"list": "列表查询:q 为模型名之后的 GQL 余部(参数列表与投影,与 REST ?q= 同源);空串 ⇒ 服务端按 schema 投影",
|
|
28
|
+
"get": "单条查询:按 id_field 精确匹配;无记录 ⇒ NOT_FOUND",
|
|
29
|
+
"create": "插入:body_json 必须为 JSON 对象编码",
|
|
30
|
+
"update": "按 id_field 定位后部分更新:set_json 必须为 JSON 对象编码;返回 store.update 原始结果",
|
|
31
|
+
"remove": "按 id_field 定位删除:返回 store.remove 原始结果",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def filter_archived(names: list[str]) -> list[str]:
|
|
36
|
+
"""归档表过滤(spec/01):`XxxDeleted` 且 `Xxx` 也在列表中 ⇒ 视为归档表(与 store-api / store-graphql 逐字一致)。"""
|
|
37
|
+
s = set(names)
|
|
38
|
+
return [n for n in names if not (n.endswith(ARCHIVE_SUFFIX) and n[: -len(ARCHIVE_SUFFIX)] in s)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def service_name(name: str) -> str:
|
|
42
|
+
"""spec/01:service 名 = 模型名首字母大写、其余原样。"""
|
|
43
|
+
return name[0].upper() + name[1:] if name else name
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _x_grpc(defn: Any) -> dict:
|
|
47
|
+
return (defn or {}).get("x-grpc") or {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def collect_services(store: Any, resources: list[str] | None = None) -> list[dict]:
|
|
51
|
+
"""收集待生成 service(归档过滤 + x-grpc 注记 + Pascal 冲突检测)。"""
|
|
52
|
+
names = resources if resources is not None else filter_archived(store.list())
|
|
53
|
+
seen: dict[str, str] = {}
|
|
54
|
+
out: list[dict] = []
|
|
55
|
+
get = getattr(store, "get", None)
|
|
56
|
+
for name in names:
|
|
57
|
+
defn = None
|
|
58
|
+
if callable(get):
|
|
59
|
+
try:
|
|
60
|
+
defn = get(name)
|
|
61
|
+
except KeyError:
|
|
62
|
+
defn = None
|
|
63
|
+
xg = _x_grpc(defn if isinstance(defn, dict) else None)
|
|
64
|
+
if xg.get("hidden"):
|
|
65
|
+
continue # spec/01 注记:模型级 hidden
|
|
66
|
+
svc = service_name(name)
|
|
67
|
+
if svc in seen:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"ERR_NAME_CONFLICT:模型 \"{name}\" 与 \"{seen[svc]}\" 的 service 名 \"{svc}\" 冲突(spec/01)"
|
|
70
|
+
)
|
|
71
|
+
seen[svc] = name
|
|
72
|
+
description = (defn or {}).get("description") if isinstance(defn, dict) else None
|
|
73
|
+
out.append({"name": name, "svc": svc, "readonly": bool(xg.get("readonly")), "description": description or ""})
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _render_service(s: dict) -> str:
|
|
78
|
+
lines: list[str] = []
|
|
79
|
+
if s["description"]:
|
|
80
|
+
lines.append(f"// {s['description']}")
|
|
81
|
+
lines.append(f"service {s['svc']} {{")
|
|
82
|
+
lines.append(f" // {_RPC_COMMENTS['list']}")
|
|
83
|
+
lines.append(f" rpc List{s['svc']} (ListRequest) returns (StoreReply);")
|
|
84
|
+
lines.append(f" // {_RPC_COMMENTS['get']}")
|
|
85
|
+
lines.append(f" rpc Get{s['svc']} (GetRequest) returns (StoreReply);")
|
|
86
|
+
if not s["readonly"]:
|
|
87
|
+
lines.append(f" // {_RPC_COMMENTS['create']}")
|
|
88
|
+
lines.append(f" rpc Create{s['svc']} (CreateRequest) returns (StoreReply);")
|
|
89
|
+
lines.append(f" // {_RPC_COMMENTS['update']}")
|
|
90
|
+
lines.append(f" rpc Update{s['svc']} (UpdateRequest) returns (StoreReply);")
|
|
91
|
+
lines.append(f" // {_RPC_COMMENTS['remove']}")
|
|
92
|
+
lines.append(f" rpc Delete{s['svc']} (DeleteRequest) returns (StoreReply);")
|
|
93
|
+
lines.append("}")
|
|
94
|
+
return "\n".join(lines)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_proto(store: Any, *, resources: list[str] | None = None) -> str:
|
|
98
|
+
"""spec/01:schema → .proto 文本(纯构建,零 gRPC 依赖)。"""
|
|
99
|
+
services = collect_services(store, resources)
|
|
100
|
+
return (
|
|
101
|
+
"// 由 store-grpc 自动生成 —— 事实源:store 的 JSON schema(与 store-api / store-graphql 同源)\n"
|
|
102
|
+
'syntax = "proto3";\n\n'
|
|
103
|
+
f"package {PACKAGE};\n\n"
|
|
104
|
+
f"{_COMMON_MESSAGES}\n"
|
|
105
|
+
+ "\n\n".join(_render_service(s) for s in services)
|
|
106
|
+
+ "\n"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def export_proto(store: Any, *, resources: list[str] | None = None) -> str:
|
|
111
|
+
"""spec/01:export_proto ≡ build_proto(命名对齐 store-graphql 的 export_sdl)。"""
|
|
112
|
+
return build_proto(store, resources=resources)
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""spec/02 — gRPC 承载(grpcio + grpcio-tools,可选依赖;同一份 proto 文本运行时编译,零手工 protoc)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import importlib.util
|
|
7
|
+
import inspect
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
import tempfile
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
from .errors import (
|
|
16
|
+
ContextError,
|
|
17
|
+
MappedError,
|
|
18
|
+
invalid_body,
|
|
19
|
+
invalid_param,
|
|
20
|
+
map_store_error,
|
|
21
|
+
not_found,
|
|
22
|
+
parse_json_field,
|
|
23
|
+
require_json_object,
|
|
24
|
+
)
|
|
25
|
+
from .proto import PACKAGE, build_proto, collect_services
|
|
26
|
+
|
|
27
|
+
ContextProvider = Callable[[Any], Any]
|
|
28
|
+
|
|
29
|
+
_REQ_CLASS_BY_VERB = {
|
|
30
|
+
"List": "ListRequest",
|
|
31
|
+
"Get": "GetRequest",
|
|
32
|
+
"Create": "CreateRequest",
|
|
33
|
+
"Update": "UpdateRequest",
|
|
34
|
+
"Delete": "DeleteRequest",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_COMPILED_TYPE_NAMES = (
|
|
39
|
+
"ListRequest",
|
|
40
|
+
"GetRequest",
|
|
41
|
+
"CreateRequest",
|
|
42
|
+
"UpdateRequest",
|
|
43
|
+
"DeleteRequest",
|
|
44
|
+
"StoreReply",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _Compiled:
|
|
49
|
+
"""编译产物:按 message 名取消息类(pb.ListRequest / pb.StoreReply ...)。"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, classes: dict):
|
|
52
|
+
for name, cls in classes.items():
|
|
53
|
+
setattr(self, name, cls)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _compile_proto(proto_text: str):
|
|
57
|
+
"""grpc_tools.protoc 编译 proto 文本 → 独立 descriptor pool 的消息类集合。
|
|
58
|
+
|
|
59
|
+
用 --descriptor_set_out + 独立 DescriptorPool(非全局 Default pool):不同 schema 组合
|
|
60
|
+
反复编译时同名 message(store.v0.ListRequest 等)不冲突;进程内按内容 hash 缓存。
|
|
61
|
+
"""
|
|
62
|
+
from google.protobuf import descriptor_pb2, descriptor_pool, message_factory
|
|
63
|
+
from grpc_tools import protoc
|
|
64
|
+
|
|
65
|
+
cache = _compile_proto._cache
|
|
66
|
+
key = hashlib.sha1(proto_text.encode("utf-8")).hexdigest()
|
|
67
|
+
if key in cache:
|
|
68
|
+
return cache[key]
|
|
69
|
+
|
|
70
|
+
tmp = tempfile.mkdtemp(prefix="store-grpc-")
|
|
71
|
+
proto_file = os.path.join(tmp, "store_grpc.proto")
|
|
72
|
+
with open(proto_file, "w", encoding="utf-8") as f:
|
|
73
|
+
f.write(proto_text)
|
|
74
|
+
desc_file = os.path.join(tmp, "store_grpc.desc")
|
|
75
|
+
rc = protoc.main(
|
|
76
|
+
["protoc", f"-I{tmp}", f"--descriptor_set_out={desc_file}", proto_file]
|
|
77
|
+
)
|
|
78
|
+
if rc != 0:
|
|
79
|
+
raise RuntimeError(f"grpc_tools.protoc 编译失败 rc={rc}")
|
|
80
|
+
|
|
81
|
+
fds = descriptor_pb2.FileDescriptorSet()
|
|
82
|
+
with open(desc_file, "rb") as f:
|
|
83
|
+
fds.ParseFromString(f.read())
|
|
84
|
+
pool = descriptor_pool.DescriptorPool()
|
|
85
|
+
for fd in fds.file:
|
|
86
|
+
pool.Add(fd)
|
|
87
|
+
|
|
88
|
+
classes = {
|
|
89
|
+
name: message_factory.GetMessageClass(pool.FindMessageTypeByName(f"{PACKAGE}.{name}"))
|
|
90
|
+
for name in _COMPILED_TYPE_NAMES
|
|
91
|
+
}
|
|
92
|
+
module = _Compiled(classes)
|
|
93
|
+
cache[key] = module
|
|
94
|
+
return module
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_compile_proto._cache = {}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _run_coro(coro):
|
|
101
|
+
"""同步承载执行协程:工作线程内建私有事件循环(py-store 实例方法为 async,spec/02 py 端差异)。"""
|
|
102
|
+
import asyncio
|
|
103
|
+
|
|
104
|
+
return asyncio.run(coro)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _run_maybe_coro(fn, *args):
|
|
108
|
+
result = fn(*args)
|
|
109
|
+
if inspect.isawaitable(result):
|
|
110
|
+
result = _run_coro(result)
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def _maybe_await(value):
|
|
115
|
+
"""store 方法同步 / async 双兼容(py-store 实例方法为 async,mock 可为同步)。"""
|
|
116
|
+
if inspect.isawaitable(value):
|
|
117
|
+
return await value
|
|
118
|
+
return value
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _schema_projection(store: Any, name: str) -> str:
|
|
122
|
+
"""spec/02:fields + computes 均视为字段(与 store-graphql 对齐);取不到 / 空 ⇒ 空串。"""
|
|
123
|
+
fields: dict | None = None
|
|
124
|
+
get = getattr(store, "get", None)
|
|
125
|
+
if callable(get):
|
|
126
|
+
try:
|
|
127
|
+
meta = get(name)
|
|
128
|
+
except KeyError:
|
|
129
|
+
meta = None
|
|
130
|
+
if isinstance(meta, dict):
|
|
131
|
+
fields = {**(meta.get("fields") or {}), **(meta.get("computes") or {})}
|
|
132
|
+
keys = list((fields or {}).keys())
|
|
133
|
+
return f" {{ {', '.join(keys)} }}" if keys else ""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _make_handlers(store: Any, s: dict, *, id_field: str, context_provider: ContextProvider | None, permission_error, pb):
|
|
137
|
+
name, svc, readonly = s["name"], s["svc"], s["readonly"]
|
|
138
|
+
proj = _schema_projection(store, name)
|
|
139
|
+
store_set_context = store.set_context
|
|
140
|
+
|
|
141
|
+
def wrap(fn: Callable[[Any], Any]):
|
|
142
|
+
def behavior(request, context):
|
|
143
|
+
try:
|
|
144
|
+
if context_provider is not None:
|
|
145
|
+
try:
|
|
146
|
+
ctx = context_provider(dict(context.invocation_metadata() or ()))
|
|
147
|
+
if inspect.isawaitable(ctx):
|
|
148
|
+
# 同步 grpcio 承载:provider 的 awaitable 结果经私有事件循环执行
|
|
149
|
+
ctx = _run_coro(ctx)
|
|
150
|
+
except MappedError:
|
|
151
|
+
raise
|
|
152
|
+
except Exception as e: # noqa: BLE001 — spec/02:权限类/前缀 ⇒ PERMISSION_DENIED;其余 ⇒ UNAUTHENTICATED
|
|
153
|
+
if permission_error is not None and isinstance(e, permission_error):
|
|
154
|
+
raise
|
|
155
|
+
if str(e).startswith("ERR_PERMISSION:"):
|
|
156
|
+
raise
|
|
157
|
+
raise ContextError(str(e) or "") from e
|
|
158
|
+
# spec/02:None 同样显式注入(set_context(None) 清除语义必须落地,防跨请求残留)
|
|
159
|
+
_run_maybe_coro(store_set_context, ctx)
|
|
160
|
+
data = _run_maybe_coro(fn, request)
|
|
161
|
+
return pb.StoreReply(data_json=json.dumps(data, ensure_ascii=False))
|
|
162
|
+
except Exception as e: # noqa: BLE001 — 单一错误出口:一切异常过 spec/03 判定链(禁静默)
|
|
163
|
+
mapped = e if isinstance(e, MappedError) else map_store_error(e, permission_error)
|
|
164
|
+
context.set_trailing_metadata((("store-error-code", mapped.code),))
|
|
165
|
+
context.abort(mapped.status, mapped.details)
|
|
166
|
+
|
|
167
|
+
return behavior
|
|
168
|
+
|
|
169
|
+
async def _list(req):
|
|
170
|
+
params = parse_json_field(req.params_json, invalid_param, "params_json")
|
|
171
|
+
# spec/02:q 缺失 → schema 投影;q 存在 → 投影完全由 q 决定,适配层不追加
|
|
172
|
+
gql = name + (req.q or proj)
|
|
173
|
+
return await _maybe_await(store.query(gql, params))
|
|
174
|
+
|
|
175
|
+
async def _get(req):
|
|
176
|
+
data = await _maybe_await(store.query_one(f"{name}($condition: @c0){proj}", {"c0": {id_field: req.id}}))
|
|
177
|
+
if data is None:
|
|
178
|
+
raise not_found(f"记录不存在: {id_field}={req.id}")
|
|
179
|
+
return data
|
|
180
|
+
|
|
181
|
+
handlers = {
|
|
182
|
+
f"List{svc}": wrap(_list),
|
|
183
|
+
f"Get{svc}": wrap(_get),
|
|
184
|
+
}
|
|
185
|
+
if not readonly:
|
|
186
|
+
|
|
187
|
+
async def _create(req):
|
|
188
|
+
body = require_json_object(req.body_json, "body_json")
|
|
189
|
+
if body is None:
|
|
190
|
+
raise invalid_body("body_json 必须是 JSON 对象编码")
|
|
191
|
+
return await _maybe_await(store.insert(name, body))
|
|
192
|
+
|
|
193
|
+
async def _update(req):
|
|
194
|
+
set_ = require_json_object(req.set_json, "set_json")
|
|
195
|
+
if set_ is None:
|
|
196
|
+
raise invalid_body("set_json 必须是 JSON 对象编码")
|
|
197
|
+
return await _maybe_await(store.update(name, {id_field: req.id}, set_))
|
|
198
|
+
|
|
199
|
+
async def _delete(req):
|
|
200
|
+
return await _maybe_await(store.remove(name, {id_field: req.id}))
|
|
201
|
+
|
|
202
|
+
handlers.update(
|
|
203
|
+
{
|
|
204
|
+
f"Create{svc}": wrap(_create),
|
|
205
|
+
f"Update{svc}": wrap(_update),
|
|
206
|
+
f"Delete{svc}": wrap(_delete),
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
return handlers
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class _Router:
|
|
213
|
+
"""按 method 路由('/store.v0.{svc}/{rpc}')→ RpcMethodHandler;未注册路径返回 None(grpcio ⇒ UNIMPLEMENTED)。"""
|
|
214
|
+
|
|
215
|
+
def __init__(self, pb: Any, routes: dict[str, Callable], handler_factory: Callable):
|
|
216
|
+
self._pb = pb
|
|
217
|
+
self._routes = routes
|
|
218
|
+
self._handler = handler_factory
|
|
219
|
+
|
|
220
|
+
def service(self, handler_call_details):
|
|
221
|
+
behavior = self._routes.get(handler_call_details.method or "")
|
|
222
|
+
if behavior is None:
|
|
223
|
+
return None
|
|
224
|
+
verb = re.match(r"^(List|Get|Create|Update|Delete)", behavior.rpc).group(1)
|
|
225
|
+
req_cls = getattr(self._pb, _REQ_CLASS_BY_VERB[verb])
|
|
226
|
+
return self._handler(
|
|
227
|
+
behavior,
|
|
228
|
+
request_deserializer=req_cls.FromString,
|
|
229
|
+
response_serializer=self._pb.StoreReply.SerializeToString,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class GrpcServer:
|
|
234
|
+
"""承载句柄:.server / .pb / .proto / .port / .stop(grace)(与 node 端 { server, proto, port, shutdown } 同构)。"""
|
|
235
|
+
|
|
236
|
+
def __init__(self, server: Any, pb: Any, proto: str, port: int | None):
|
|
237
|
+
self.server = server
|
|
238
|
+
self.pb = pb
|
|
239
|
+
self.proto = proto
|
|
240
|
+
self.port = port
|
|
241
|
+
|
|
242
|
+
def stop(self, grace: float | None = None) -> None:
|
|
243
|
+
self.server.stop(grace)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def create_server(
|
|
247
|
+
store: Any,
|
|
248
|
+
*,
|
|
249
|
+
port: int | None = None,
|
|
250
|
+
host: str = "127.0.0.1",
|
|
251
|
+
resources: list[str] | None = None,
|
|
252
|
+
id_field: str = "_id",
|
|
253
|
+
context_provider: ContextProvider | None = None,
|
|
254
|
+
permission_error: type[BaseException] | None = None,
|
|
255
|
+
) -> GrpcServer:
|
|
256
|
+
"""spec/02:注册 service 并按需监听。port 给定 ⇒ start 后返回(0 ⇒ 随机端口);None ⇒ 仅注册不监听。
|
|
257
|
+
|
|
258
|
+
承载依赖缺失 ⇒ 抛带安装指引的错误(禁静默降级)。
|
|
259
|
+
"""
|
|
260
|
+
try:
|
|
261
|
+
import grpc # noqa: F401
|
|
262
|
+
from grpc_tools import protoc # noqa: F401
|
|
263
|
+
except ImportError as e: # noqa: F841 — 报错信息自身已含原因
|
|
264
|
+
raise ImportError(
|
|
265
|
+
"create_server 需要安装 grpcio 与 grpcio-tools:pip install 'store-grpc-py[grpc]';"
|
|
266
|
+
"或仅用 build_proto / export_proto 自行承载"
|
|
267
|
+
) from e
|
|
268
|
+
|
|
269
|
+
import grpc
|
|
270
|
+
|
|
271
|
+
proto_text = build_proto(store, resources=resources)
|
|
272
|
+
pb = _compile_proto(proto_text)
|
|
273
|
+
services = collect_services(store, resources)
|
|
274
|
+
|
|
275
|
+
if permission_error is None:
|
|
276
|
+
permission_error = getattr(store, "PermissionError", None)
|
|
277
|
+
|
|
278
|
+
routes: dict[str, Any] = {}
|
|
279
|
+
for s in services:
|
|
280
|
+
handlers = _make_handlers(
|
|
281
|
+
store, s, id_field=id_field, context_provider=context_provider, permission_error=permission_error, pb=pb
|
|
282
|
+
)
|
|
283
|
+
for rpc, behavior in handlers.items():
|
|
284
|
+
behavior.rpc = rpc
|
|
285
|
+
routes[f"/{PACKAGE}.{s['svc']}/{rpc}"] = behavior
|
|
286
|
+
|
|
287
|
+
from concurrent import futures
|
|
288
|
+
|
|
289
|
+
server = grpc.server(futures.ThreadPoolExecutor(max_workers=8), options=None)
|
|
290
|
+
server.add_generic_rpc_handlers([_Router(pb, routes, grpc.unary_unary_rpc_method_handler)])
|
|
291
|
+
|
|
292
|
+
if port is None:
|
|
293
|
+
return GrpcServer(server, pb, proto_text, None)
|
|
294
|
+
|
|
295
|
+
bound = server.add_insecure_port(f"{host}:{port}")
|
|
296
|
+
if bound == 0:
|
|
297
|
+
raise RuntimeError(f"grpc server 绑定端口失败: {host}:{port}")
|
|
298
|
+
server.start()
|
|
299
|
+
return GrpcServer(server, pb, proto_text, bound)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: store-grpc-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: gRPC adapter that auto-generates a .proto service per schema for py-store (schema-driven CRUD over GQL, proto-first with runtime compilation, zero manual protoc)
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: py-store,storepy,grpc,protobuf,crud,gql
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Provides-Extra: grpc
|
|
10
|
+
Requires-Dist: grpcio>=1.60; extra == "grpc"
|
|
11
|
+
Requires-Dist: grpcio-tools>=1.60; extra == "grpc"
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
14
|
+
Requires-Dist: grpcio>=1.60; extra == "dev"
|
|
15
|
+
Requires-Dist: grpcio-tools>=1.60; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# store-grpc-py
|
|
18
|
+
|
|
19
|
+
py-store 的 gRPC 皮肤(`store-grpc-py`,PyPI)。规范与用法见仓库根 README 与 `../spec/`。
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/store_grpc/__init__.py
|
|
4
|
+
src/store_grpc/errors.py
|
|
5
|
+
src/store_grpc/proto.py
|
|
6
|
+
src/store_grpc/server.py
|
|
7
|
+
src/store_grpc_py.egg-info/PKG-INFO
|
|
8
|
+
src/store_grpc_py.egg-info/SOURCES.txt
|
|
9
|
+
src/store_grpc_py.egg-info/dependency_links.txt
|
|
10
|
+
src/store_grpc_py.egg-info/requires.txt
|
|
11
|
+
src/store_grpc_py.egg-info/top_level.txt
|
|
12
|
+
tests/test_conformance.py
|
|
13
|
+
tests/test_smoke.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
store_grpc
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""conformance runner — 读取共享用例 JSON(../../conformance/cases/*.json)逐项执行,
|
|
2
|
+
与 node 端 runner 断言同一份文件(spec 唯一事实源)。
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
grpc = pytest.importorskip("grpc")
|
|
12
|
+
pytest.importorskip("grpc_tools")
|
|
13
|
+
|
|
14
|
+
from store_grpc import create_server # noqa: E402
|
|
15
|
+
|
|
16
|
+
CASES_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "conformance", "cases")
|
|
17
|
+
|
|
18
|
+
STATUS_BY_NAME = {
|
|
19
|
+
"INVALID_ARGUMENT": grpc.StatusCode.INVALID_ARGUMENT,
|
|
20
|
+
"UNAUTHENTICATED": grpc.StatusCode.UNAUTHENTICATED,
|
|
21
|
+
"PERMISSION_DENIED": grpc.StatusCode.PERMISSION_DENIED,
|
|
22
|
+
"NOT_FOUND": grpc.StatusCode.NOT_FOUND,
|
|
23
|
+
"INTERNAL": grpc.StatusCode.INTERNAL,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
REQ_FIELD_BY_RPC = {
|
|
27
|
+
"List": "ListRequest",
|
|
28
|
+
"Get": "GetRequest",
|
|
29
|
+
"Create": "CreateRequest",
|
|
30
|
+
"Update": "UpdateRequest",
|
|
31
|
+
"Delete": "DeleteRequest",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PermissionErrorMock(Exception):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def make_mock_store(defn):
|
|
40
|
+
rows = []
|
|
41
|
+
|
|
42
|
+
def guard_gql(gql):
|
|
43
|
+
# 模拟 core 的 ERR_GQL_PARSE: 前缀契约:括号不配平即解析失败
|
|
44
|
+
if gql.count("(") != gql.count(")"):
|
|
45
|
+
raise ValueError("ERR_GQL_PARSE:括号不配平")
|
|
46
|
+
|
|
47
|
+
class Store:
|
|
48
|
+
PermissionError = PermissionErrorMock
|
|
49
|
+
list = staticmethod(lambda: [defn["name"]])
|
|
50
|
+
get = staticmethod(lambda name: defn)
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def query(gql, params=None):
|
|
54
|
+
guard_gql(gql)
|
|
55
|
+
cond = (params or {}).get("c0")
|
|
56
|
+
return [r for r in rows if not cond or all(r.get(k) == v for k, v in cond.items())]
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def query_one(gql, params=None):
|
|
60
|
+
guard_gql(gql)
|
|
61
|
+
cond = (params or {}).get("c0")
|
|
62
|
+
return next((r for r in rows if all(r.get(k) == v for k, v in cond.items())), None)
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def insert(name, data):
|
|
66
|
+
doc = {"_id": f"u{len(rows) + 1}", **data}
|
|
67
|
+
rows.append(doc)
|
|
68
|
+
return doc
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def update(name, cond, data):
|
|
72
|
+
row = next(r for r in rows if all(r.get(k) == v for k, v in cond.items()))
|
|
73
|
+
row.update(data)
|
|
74
|
+
return row
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def remove(name, cond):
|
|
78
|
+
for i, r in enumerate(rows):
|
|
79
|
+
if all(r.get(k) == v for k, v in cond.items()):
|
|
80
|
+
rows.pop(i)
|
|
81
|
+
return 1
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def set_context(ctx):
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
return Store()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def encode_request(pb, svc, step_request):
|
|
92
|
+
"""conformance 通用形状 → py 侧 snake_case 字段;字符串值原样传递(预编码/非法样本),对象才 dumps。"""
|
|
93
|
+
verb = re.match(r"^(List|Get|Create|Update|Delete)", step_request.get("rpc", "List")).group(1)
|
|
94
|
+
req_cls = getattr(pb, REQ_FIELD_BY_RPC[verb])
|
|
95
|
+
kwargs = {}
|
|
96
|
+
if step_request.get("q") is not None:
|
|
97
|
+
kwargs["q"] = step_request["q"]
|
|
98
|
+
if step_request.get("id") is not None:
|
|
99
|
+
kwargs["id"] = step_request["id"]
|
|
100
|
+
if step_request.get("params") is not None:
|
|
101
|
+
p = step_request["params"]
|
|
102
|
+
kwargs["params_json"] = p if isinstance(p, str) else json.dumps(p)
|
|
103
|
+
if step_request.get("body") is not None:
|
|
104
|
+
kwargs["body_json"] = json.dumps(step_request["body"])
|
|
105
|
+
if step_request.get("set") is not None:
|
|
106
|
+
kwargs["set_json"] = json.dumps(step_request["set"])
|
|
107
|
+
return req_cls(**kwargs)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.parametrize("file", [f for f in os.listdir(CASES_DIR) if f.endswith(".json")])
|
|
111
|
+
def test_conformance(file):
|
|
112
|
+
with open(os.path.join(CASES_DIR, file), encoding="utf-8") as f:
|
|
113
|
+
spec = json.load(f)
|
|
114
|
+
svc = spec["service"]
|
|
115
|
+
defn = {"name": svc, "fields": {"_id": {"type": "string"}, "name": {"type": "string"}, "age": {"type": "int"}}}
|
|
116
|
+
store = make_mock_store(defn)
|
|
117
|
+
server = create_server(store, port=0)
|
|
118
|
+
try:
|
|
119
|
+
pb = server.pb
|
|
120
|
+
channel = grpc.insecure_channel(f"127.0.0.1:{server.port}")
|
|
121
|
+
try:
|
|
122
|
+
created_id = None
|
|
123
|
+
for i, step in enumerate(spec["steps"]):
|
|
124
|
+
request = encode_request(pb, svc, {"rpc": step["rpc"], **(step.get("request") or {})})
|
|
125
|
+
if step.get("useCreatedId") and getattr(request, "id", "") == "":
|
|
126
|
+
request.id = created_id
|
|
127
|
+
unary = channel.unary_unary(
|
|
128
|
+
f"/store.v0.{svc}/{step['rpc']}",
|
|
129
|
+
request_serializer=type(request).SerializeToString,
|
|
130
|
+
response_deserializer=pb.StoreReply.FromString,
|
|
131
|
+
)
|
|
132
|
+
try:
|
|
133
|
+
reply = unary(request, timeout=10)
|
|
134
|
+
error = None
|
|
135
|
+
except grpc.RpcError as e:
|
|
136
|
+
reply = None
|
|
137
|
+
error = e
|
|
138
|
+
|
|
139
|
+
expect = step["expect"]
|
|
140
|
+
if expect.get("ok"):
|
|
141
|
+
assert error is None, f"step#{i} {step['rpc']} 应成功: {error and error.details}"
|
|
142
|
+
data = json.loads(reply.data_json)
|
|
143
|
+
if "dataKeys" in expect:
|
|
144
|
+
assert sorted(k for k in data if k in expect["dataKeys"]) == sorted(expect["dataKeys"])
|
|
145
|
+
if "listLength" in expect:
|
|
146
|
+
assert len(data) == expect["listLength"]
|
|
147
|
+
if i == 0:
|
|
148
|
+
created_id = data["_id"]
|
|
149
|
+
else:
|
|
150
|
+
assert error is not None, f"step#{i} {step['rpc']} 应失败"
|
|
151
|
+
assert error.code() == STATUS_BY_NAME[expect["statusCode"]], f"step#{i} status"
|
|
152
|
+
assert dict(error.trailing_metadata())["store-error-code"] == expect["errorCode"], f"step#{i} code"
|
|
153
|
+
finally:
|
|
154
|
+
channel.close()
|
|
155
|
+
finally:
|
|
156
|
+
server.stop(None)
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""冒烟测试 — mock store + 真实 grpcio server(本地随机端口,不连真实库)。
|
|
2
|
+
|
|
3
|
+
用例语义与 node 端 smoke / ../../conformance/cases/users-crud.json 对齐(spec 唯一事实源)。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
grpc = pytest.importorskip("grpc")
|
|
12
|
+
pytest.importorskip("grpc_tools")
|
|
13
|
+
|
|
14
|
+
from store_grpc import build_proto, create_server, export_proto, filter_archived, service_name # noqa: E402
|
|
15
|
+
|
|
16
|
+
DEFN = {
|
|
17
|
+
"name": "User",
|
|
18
|
+
"description": "用户表:平台账号主档",
|
|
19
|
+
"fields": {
|
|
20
|
+
"_id": {"type": "string", "description": "主键,u 前缀"},
|
|
21
|
+
"name": {"type": "string"},
|
|
22
|
+
"age": {"type": "int"},
|
|
23
|
+
"profile": {"type": "object", "description": "个人资料", "fields": {"bio": {"type": "string"}}},
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
STATUS = grpc.StatusCode
|
|
28
|
+
REQ_CLASS_BY_RPC = {
|
|
29
|
+
"List": "ListRequest",
|
|
30
|
+
"Get": "GetRequest",
|
|
31
|
+
"Create": "CreateRequest",
|
|
32
|
+
"Update": "UpdateRequest",
|
|
33
|
+
"Delete": "DeleteRequest",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PermissionErrorMock(Exception):
|
|
38
|
+
"""实例 name 显式化(真实 store 权限类有稳定码/name;Python 类名即 __name__)。"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def make_mock_store():
|
|
42
|
+
rows = []
|
|
43
|
+
state = {"last_ctx": "initial-not-cleaned"}
|
|
44
|
+
|
|
45
|
+
def guard_gql(gql):
|
|
46
|
+
# 模拟 core 的 ERR_GQL_PARSE: 前缀契约:括号不配平即解析失败(conformance GQL_PARSE 用例依赖)
|
|
47
|
+
if gql.count("(") != gql.count(")"):
|
|
48
|
+
raise ValueError("ERR_GQL_PARSE:括号不配平")
|
|
49
|
+
|
|
50
|
+
class Store:
|
|
51
|
+
PermissionError = PermissionErrorMock
|
|
52
|
+
list = staticmethod(lambda: ["User"])
|
|
53
|
+
get = staticmethod(lambda name: DEFN)
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
async def query(gql, params=None):
|
|
57
|
+
guard_gql(gql)
|
|
58
|
+
cond = (params or {}).get("c0")
|
|
59
|
+
return [r for r in rows if not cond or all(r.get(k) == v for k, v in cond.items())]
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
async def query_one(gql, params=None):
|
|
63
|
+
guard_gql(gql)
|
|
64
|
+
cond = (params or {}).get("c0")
|
|
65
|
+
return next((r for r in rows if all(r.get(k) == v for k, v in cond.items())), None)
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
async def insert(name, data):
|
|
69
|
+
doc = {"_id": f"u{len(rows) + 1}", **data}
|
|
70
|
+
rows.append(doc)
|
|
71
|
+
return doc
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
async def update(name, cond, data):
|
|
75
|
+
row = next(r for r in rows if all(r.get(k) == v for k, v in cond.items()))
|
|
76
|
+
row.update(data)
|
|
77
|
+
return row
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
async def remove(name, cond):
|
|
81
|
+
for i, r in enumerate(rows):
|
|
82
|
+
if all(r.get(k) == v for k, v in cond.items()):
|
|
83
|
+
rows.pop(i)
|
|
84
|
+
return 1
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
async def set_context(ctx):
|
|
89
|
+
state["last_ctx"] = ctx
|
|
90
|
+
|
|
91
|
+
return Store(), rows, state
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def err_details(e):
|
|
95
|
+
"""grpcio 错误 details:活跃错误为属性、_InactiveRpcError 为方法,统一取值。"""
|
|
96
|
+
d = e.details
|
|
97
|
+
return d() if callable(d) else d
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def make_client(h, svc="User", readonly=False):
|
|
101
|
+
"""用 server 暴露的 proto 文本编译产物构造 client(与生产客户端同路径)。"""
|
|
102
|
+
pb = h.pb
|
|
103
|
+
verbs = ["List", "Get"] if readonly else ["List", "Get", "Create", "Update", "Delete"]
|
|
104
|
+
|
|
105
|
+
def call(rpc, request):
|
|
106
|
+
verb = re.match(r"^(List|Get|Create|Update|Delete)", rpc).group(1)
|
|
107
|
+
req_cls = getattr(pb, REQ_CLASS_BY_RPC[verb])
|
|
108
|
+
reply_cls = pb.StoreReply
|
|
109
|
+
channel = grpc.insecure_channel(f"127.0.0.1:{h.port}")
|
|
110
|
+
try:
|
|
111
|
+
unary = channel.unary_unary(
|
|
112
|
+
f"/store.v0.{svc}/{rpc}",
|
|
113
|
+
request_serializer=req_cls.SerializeToString,
|
|
114
|
+
response_deserializer=reply_cls.FromString,
|
|
115
|
+
)
|
|
116
|
+
try:
|
|
117
|
+
return {"reply": unary(request, timeout=10)}
|
|
118
|
+
except grpc.RpcError as e:
|
|
119
|
+
return {"error": e}
|
|
120
|
+
finally:
|
|
121
|
+
channel.close()
|
|
122
|
+
|
|
123
|
+
return call
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@pytest.fixture()
|
|
127
|
+
def h():
|
|
128
|
+
store, rows, state = make_mock_store()
|
|
129
|
+
server = create_server(store, port=0)
|
|
130
|
+
yield server, store, rows, state
|
|
131
|
+
server.stop(None)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_filter_archived():
|
|
135
|
+
assert filter_archived(["User", "UserDeleted", "Log"]) == ["User", "Log"]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_service_name():
|
|
139
|
+
assert service_name("user") == "User"
|
|
140
|
+
assert service_name("User") == "User"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_build_proto():
|
|
144
|
+
store, _, _ = make_mock_store()
|
|
145
|
+
proto = build_proto(store)
|
|
146
|
+
assert 'syntax = "proto3";' in proto
|
|
147
|
+
assert "package store.v0;" in proto
|
|
148
|
+
assert "service User {" in proto
|
|
149
|
+
assert "rpc ListUser (ListRequest) returns (StoreReply);" in proto
|
|
150
|
+
assert "rpc GetUser (GetRequest) returns (StoreReply);" in proto
|
|
151
|
+
assert "rpc CreateUser (CreateRequest) returns (StoreReply);" in proto
|
|
152
|
+
assert "rpc UpdateUser (UpdateRequest) returns (StoreReply);" in proto
|
|
153
|
+
assert "rpc DeleteUser (DeleteRequest) returns (StoreReply);" in proto
|
|
154
|
+
assert "// 用户表:平台账号主档" in proto # description 透传(spec/01)
|
|
155
|
+
assert export_proto(store) == proto # export_proto ≡ build_proto(spec/01)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_build_proto_notes_and_conflict():
|
|
159
|
+
store, _, _ = make_mock_store()
|
|
160
|
+
store.list = lambda: ["User", "SecretLog", "AuditEvent"]
|
|
161
|
+
store.get = lambda n: (
|
|
162
|
+
DEFN
|
|
163
|
+
if n == "User"
|
|
164
|
+
else {**DEFN, "name": "SecretLog", "x-grpc": {"hidden": True}}
|
|
165
|
+
if n == "SecretLog"
|
|
166
|
+
else {**DEFN, "name": "AuditEvent", "x-grpc": {"readonly": True}}
|
|
167
|
+
)
|
|
168
|
+
proto = build_proto(store)
|
|
169
|
+
assert "SecretLog" not in proto
|
|
170
|
+
assert "service AuditEvent {" in proto
|
|
171
|
+
assert "rpc ListAuditEvent" in proto
|
|
172
|
+
assert "rpc CreateAuditEvent" not in proto # readonly → 只出查询两段
|
|
173
|
+
|
|
174
|
+
store.list = lambda: ["user", "User"]
|
|
175
|
+
with pytest.raises(ValueError, match="ERR_NAME_CONFLICT"):
|
|
176
|
+
build_proto(store)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_five_rpc_full_chain(h):
|
|
180
|
+
server, store, rows, _ = h
|
|
181
|
+
call = make_client(server)
|
|
182
|
+
pb = server.pb
|
|
183
|
+
|
|
184
|
+
# CreateUser
|
|
185
|
+
r = call("CreateUser", pb.CreateRequest(body_json=json.dumps({"name": "alice", "age": 30})))
|
|
186
|
+
assert r["reply"].data_json == '{"_id": "u1", "name": "alice", "age": 30}'
|
|
187
|
+
|
|
188
|
+
# ListUser(q 透传 + params_json)
|
|
189
|
+
r = call(
|
|
190
|
+
"ListUser",
|
|
191
|
+
pb.ListRequest(q="($condition: @c0) { name, age }", params_json=json.dumps({"c0": {"age": 30}})),
|
|
192
|
+
)
|
|
193
|
+
assert json.loads(r["reply"].data_json)[0]["name"] == "alice"
|
|
194
|
+
|
|
195
|
+
# ListUser(q 缺省 → schema 投影拼接;投影串由适配层拼进 GQL)
|
|
196
|
+
r = call("ListUser", pb.ListRequest())
|
|
197
|
+
assert r["reply"].data_json # 成功即可,投影语义由 smoke 的 gqlLog 用例覆盖
|
|
198
|
+
|
|
199
|
+
# GetUser
|
|
200
|
+
r = call("GetUser", pb.GetRequest(id="u1"))
|
|
201
|
+
assert json.loads(r["reply"].data_json) == {"_id": "u1", "name": "alice", "age": 30}
|
|
202
|
+
|
|
203
|
+
# UpdateUser(返回 store.update 原始结果,不做二次回读——spec/02 对齐 REST)
|
|
204
|
+
r = call("UpdateUser", pb.UpdateRequest(id="u1", set_json=json.dumps({"age": 31})))
|
|
205
|
+
assert json.loads(r["reply"].data_json) == {"_id": "u1", "name": "alice", "age": 31}
|
|
206
|
+
|
|
207
|
+
# DeleteUser
|
|
208
|
+
r = call("DeleteUser", pb.DeleteRequest(id="u1"))
|
|
209
|
+
assert json.loads(r["reply"].data_json) == 1
|
|
210
|
+
|
|
211
|
+
# 删除后 GetUser ⇒ NOT_FOUND + trailer store-error-code(spec/03)
|
|
212
|
+
r = call("GetUser", pb.GetRequest(id="u1"))
|
|
213
|
+
err = r["error"]
|
|
214
|
+
assert err.code() == STATUS.NOT_FOUND
|
|
215
|
+
assert err_details(err) == "记录不存在: _id=u1"
|
|
216
|
+
assert dict(err.trailing_metadata())["store-error-code"] == "NOT_FOUND"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_error_mapping(h):
|
|
220
|
+
server, store, _, _ = h
|
|
221
|
+
call = make_client(server)
|
|
222
|
+
pb = server.pb
|
|
223
|
+
|
|
224
|
+
# body_json 数组 / 非法 JSON ⇒ INVALID_ARGUMENT + INVALID_BODY
|
|
225
|
+
assert call("CreateUser", pb.CreateRequest(body_json="[1,2]"))["error"].code() == STATUS.INVALID_ARGUMENT
|
|
226
|
+
r = call("CreateUser", pb.CreateRequest(body_json="{oops"))
|
|
227
|
+
assert dict(r["error"].trailing_metadata())["store-error-code"] == "INVALID_BODY"
|
|
228
|
+
|
|
229
|
+
# params_json 非法 JSON ⇒ INVALID_PARAM
|
|
230
|
+
r = call("ListUser", pb.ListRequest(params_json="not-json"))
|
|
231
|
+
assert r["error"].code() == STATUS.INVALID_ARGUMENT
|
|
232
|
+
assert dict(r["error"].trailing_metadata())["store-error-code"] == "INVALID_PARAM"
|
|
233
|
+
|
|
234
|
+
# core GQL 解析失败(ERR_GQL_PARSE: 前缀)⇒ GQL_PARSE,details 剥前缀
|
|
235
|
+
r = call("ListUser", pb.ListRequest(q="($bogus"))
|
|
236
|
+
assert dict(r["error"].trailing_metadata())["store-error-code"] == "GQL_PARSE"
|
|
237
|
+
assert err_details(r["error"]) == "括号不配平"
|
|
238
|
+
|
|
239
|
+
# ERR_PERMISSION: 前缀 ⇒ PERMISSION_DENIED
|
|
240
|
+
orig_query = store.query
|
|
241
|
+
|
|
242
|
+
def deny(gql, params=None):
|
|
243
|
+
raise ValueError("ERR_PERMISSION:无访问权限")
|
|
244
|
+
|
|
245
|
+
store.query = deny
|
|
246
|
+
r = call("ListUser", pb.ListRequest())
|
|
247
|
+
assert r["error"].code() == STATUS.PERMISSION_DENIED
|
|
248
|
+
assert dict(r["error"].trailing_metadata())["store-error-code"] == "ERR_PERMISSION"
|
|
249
|
+
|
|
250
|
+
# PermissionError 类(store.PermissionError 来源)⇒ PERMISSION_DENIED,码取 store_code
|
|
251
|
+
|
|
252
|
+
def deny_class(gql, params=None):
|
|
253
|
+
raise PermissionErrorMock("RBAC 拒绝")
|
|
254
|
+
|
|
255
|
+
store.query = deny_class
|
|
256
|
+
r = call("ListUser", pb.ListRequest())
|
|
257
|
+
assert r["error"].code() == STATUS.PERMISSION_DENIED
|
|
258
|
+
assert dict(r["error"].trailing_metadata())["store-error-code"] == "PermissionErrorMock"
|
|
259
|
+
|
|
260
|
+
# 其余 store 错误 ⇒ INTERNAL,message 原样透传(禁掩盖)
|
|
261
|
+
|
|
262
|
+
def boom(gql, params=None):
|
|
263
|
+
raise RuntimeError("数据库连接失败")
|
|
264
|
+
|
|
265
|
+
store.query = boom
|
|
266
|
+
r = call("ListUser", pb.ListRequest())
|
|
267
|
+
assert r["error"].code() == STATUS.INTERNAL
|
|
268
|
+
assert err_details(r["error"]) == "数据库连接失败"
|
|
269
|
+
assert dict(r["error"].trailing_metadata())["store-error-code"] == "RuntimeError"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_context_provider(h):
|
|
273
|
+
server, store, _, state = h
|
|
274
|
+
call = make_client(server)
|
|
275
|
+
pb = server.pb
|
|
276
|
+
|
|
277
|
+
def provider(metadata):
|
|
278
|
+
user = metadata.get("x-user")
|
|
279
|
+
if user == "bad":
|
|
280
|
+
raise ValueError("ERR_PERMISSION:无访问权限")
|
|
281
|
+
if user == "broken":
|
|
282
|
+
raise ValueError("上下文钩子故障")
|
|
283
|
+
if user == "anon":
|
|
284
|
+
return None # 显式空上下文
|
|
285
|
+
return {"user": user}
|
|
286
|
+
|
|
287
|
+
# 重新起一个带 provider 的 server
|
|
288
|
+
server.stop(None)
|
|
289
|
+
store2, rows2, state2 = make_mock_store()
|
|
290
|
+
server2 = create_server(store2, port=0, context_provider=provider)
|
|
291
|
+
try:
|
|
292
|
+
call2 = make_client(server2)
|
|
293
|
+
chan = grpc.insecure_channel(f"127.0.0.1:{server2.port}")
|
|
294
|
+
unary = chan.unary_unary(
|
|
295
|
+
"/store.v0.User/ListUser",
|
|
296
|
+
request_serializer=pb.ListRequest.SerializeToString,
|
|
297
|
+
response_deserializer=pb.StoreReply.FromString,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def raw(user):
|
|
301
|
+
md = (("x-user", user),)
|
|
302
|
+
try:
|
|
303
|
+
unary(pb.ListRequest(q=" { _id }"), metadata=md, timeout=10)
|
|
304
|
+
return None
|
|
305
|
+
except grpc.RpcError as e:
|
|
306
|
+
return e
|
|
307
|
+
|
|
308
|
+
# 注入:set_context 收到 provider 返回的对象
|
|
309
|
+
raw("alice")
|
|
310
|
+
assert state2["last_ctx"] == {"user": "alice"}
|
|
311
|
+
|
|
312
|
+
# ERR_PERMISSION: 前缀 ⇒ PERMISSION_DENIED
|
|
313
|
+
err = raw("bad")
|
|
314
|
+
assert err.code() == STATUS.PERMISSION_DENIED
|
|
315
|
+
|
|
316
|
+
# 非权限类 ⇒ UNAUTHENTICATED,message 原样透传
|
|
317
|
+
err = raw("broken")
|
|
318
|
+
assert err.code() == STATUS.UNAUTHENTICATED
|
|
319
|
+
assert err_details(err) == "上下文钩子故障"
|
|
320
|
+
|
|
321
|
+
# 返回 None ⇒ 显式 set_context(None)(清除语义落地,防上一请求上下文残留)
|
|
322
|
+
raw("anon")
|
|
323
|
+
assert state2["last_ctx"] is None
|
|
324
|
+
chan.close()
|
|
325
|
+
finally:
|
|
326
|
+
server2.stop(None)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def test_readonly_model():
|
|
330
|
+
store, _, _ = make_mock_store()
|
|
331
|
+
store.list = lambda: ["AuditEvent"]
|
|
332
|
+
store.get = lambda n: {**DEFN, "name": n, "x-grpc": {"readonly": True}}
|
|
333
|
+
server = create_server(store, port=0)
|
|
334
|
+
try:
|
|
335
|
+
call = make_client(server, svc="AuditEvent", readonly=True)
|
|
336
|
+
pb = server.pb
|
|
337
|
+
assert json.loads(call("ListAuditEvent", pb.ListRequest())["reply"].data_json) == []
|
|
338
|
+
assert call("GetAuditEvent", pb.GetRequest(id="x"))["error"].code() == STATUS.NOT_FOUND
|
|
339
|
+
finally:
|
|
340
|
+
server.stop(None)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def test_proto_roundtrip():
|
|
344
|
+
store, _, _ = make_mock_store()
|
|
345
|
+
proto = export_proto(store)
|
|
346
|
+
from grpc_tools import protoc
|
|
347
|
+
import os
|
|
348
|
+
import tempfile
|
|
349
|
+
|
|
350
|
+
tmp = tempfile.mkdtemp()
|
|
351
|
+
f = os.path.join(tmp, "store_grpc.proto")
|
|
352
|
+
with open(f, "w", encoding="utf-8") as fh:
|
|
353
|
+
fh.write(proto)
|
|
354
|
+
assert protoc.main(["protoc", f"-I{tmp}", f"--python_out={tmp}", f]) == 0 # 可再编译 = 合法 IDL
|