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.
@@ -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,3 @@
1
+ # store-grpc-py
2
+
3
+ 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,9 @@
1
+
2
+ [dev]
3
+ pytest>=8
4
+ grpcio>=1.60
5
+ grpcio-tools>=1.60
6
+
7
+ [grpc]
8
+ grpcio>=1.60
9
+ grpcio-tools>=1.60
@@ -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