store-gateway-py 0.1.1__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,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: store-gateway-py
3
+ Version: 0.1.1
4
+ Summary: Optional combiner for the three py-store API skins (REST store-api-py / GraphQL store-graphql-py / gRPC store-grpc-py): config-driven per-skin enable switches
5
+ License: MIT
6
+ Keywords: py-store,storepy,gateway,rest,graphql,grpc,pluggable
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: fastapi>=0.110
10
+ Requires-Dist: uvicorn>=0.29
11
+ Provides-Extra: rest
12
+ Requires-Dist: store-api-py; extra == "rest"
13
+ Provides-Extra: graphql
14
+ Requires-Dist: store-graphql-py; extra == "graphql"
15
+ Provides-Extra: grpc
16
+ Requires-Dist: store-grpc-py; extra == "grpc"
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8; extra == "dev"
19
+ Requires-Dist: httpx>=0.27; extra == "dev"
20
+
21
+ # store-gateway-py
22
+
23
+ py-store 三层 API 皮肤(REST / GraphQL / gRPC)的可选组合器(`store-gateway-py`)。规范与用法见仓库根 README 与 `../spec/`。
24
+
25
+ 本地开发时三皮肤以各自仓库 editable 安装:
26
+
27
+ ```bash
28
+ pip install -e ../store-api/py ../store-graphql/py ../store-grpc/py
29
+ ```
@@ -0,0 +1,9 @@
1
+ # store-gateway-py
2
+
3
+ py-store 三层 API 皮肤(REST / GraphQL / gRPC)的可选组合器(`store-gateway-py`)。规范与用法见仓库根 README 与 `../spec/`。
4
+
5
+ 本地开发时三皮肤以各自仓库 editable 安装:
6
+
7
+ ```bash
8
+ pip install -e ../store-api/py ../store-graphql/py ../store-grpc/py
9
+ ```
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "store-gateway-py"
3
+ version = "0.1.1"
4
+ description = "Optional combiner for the three py-store API skins (REST store-api-py / GraphQL store-graphql-py / gRPC store-grpc-py): config-driven per-skin enable switches"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ keywords = ["py-store", "storepy", "gateway", "rest", "graphql", "grpc", "pluggable"]
9
+ dependencies = ["fastapi>=0.110", "uvicorn>=0.29"]
10
+
11
+ [project.optional-dependencies]
12
+ rest = ["store-api-py"]
13
+ graphql = ["store-graphql-py"]
14
+ grpc = ["store-grpc-py"]
15
+ # 皮肤包(store-api-py / store-graphql-py / store-grpc-py)本地开发时以
16
+ # `pip install -e` 各自仓库路径安装(发布 PyPI 前不进 dev extras)
17
+ dev = ["pytest>=8", "httpx>=0.27"]
18
+
19
+ [build-system]
20
+ requires = ["setuptools>=68"]
21
+ build-backend = "setuptools.build_meta"
22
+
23
+ [tool.setuptools.packages.find]
24
+ where = ["src"]
25
+
26
+ [tool.pytest.ini_options]
27
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,201 @@
1
+ """store_gateway_py — 三层皮肤(REST / GraphQL / gRPC)的可选组合器。
2
+
3
+ 语义依据:../../spec/00-protocol.md(插拔协议唯一事实源)。
4
+ 本层零业务语义:读配置 → 按需实例化各皮肤 → 挂到各自 server。
5
+ 三皮肤一律 optional:enabled=True 才导入,缺失时抛带安装指引的 ImportError(禁静默降级)。
6
+
7
+ 运行期重装配(对齐 node store-gateway):HTTP 侧(REST + GraphQL)的装配抽为 `_assemble()`,
8
+ `POST /-/reload` 经 `Gateway.reload()` 重建后**原子替换**在途 ASGI 应用(构建成功才替换;
9
+ 失败保留旧 app 并返回 500)。gRPC 为独立 server,不在 reload 范围(spec/00 规则 5)。
10
+ D1 闭环桥:配置 `opts['reload'] = {'tenant','env'}` 时,reload 在重装配**之前**先按 ns 调
11
+ `store.restore_defs({tenant, env})`(宿主提供),使控制面 publish 落库的新定义经一次 reload 可见。
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import importlib
18
+ import inspect
19
+ import json
20
+ from typing import Any
21
+
22
+ __all__ = ["build"]
23
+
24
+
25
+ def _optional_import(module_name: str, pip_hint: str):
26
+ try:
27
+ return importlib.import_module(module_name)
28
+ except ImportError as e:
29
+ raise ImportError(f"启用该皮肤需要安装 {module_name}(pip install '{pip_hint}')") from e
30
+
31
+
32
+ def _read_skins(opts: dict) -> dict:
33
+ rest = {"enabled": False, **(opts.get("rest") or {})}
34
+ graphql = {"enabled": False, **(opts.get("graphql") or {})}
35
+ grpc = {"enabled": False, **(opts.get("grpc") or {})}
36
+ if not (rest["enabled"] or graphql["enabled"] or grpc["enabled"]):
37
+ raise ValueError("ERR_NO_SKIN:rest / graphql / grpc 至少启用一项(spec/00-protocol.md 插拔规则 2)")
38
+ return {"rest": rest, "graphql": graphql, "grpc": grpc}
39
+
40
+
41
+ def _read_reload(opts: dict) -> dict | None:
42
+ """解析 reload 重建配置(进程级单 ns):`opts['reload'] = {'tenant','env'}`。
43
+
44
+ 未配置 → None(reload 只重跑既有装配,保持既有语义);配置但缺 tenant/env → 显式报错(fail-fast)。
45
+ """
46
+ cfg = opts.get("reload")
47
+ if not cfg:
48
+ return None
49
+ tenant, env = cfg.get("tenant"), cfg.get("env")
50
+ if not tenant or not env:
51
+ raise ValueError("ERR_RELOAD_CONFIG:opts.reload 须提供 tenant 与 env(进程级单 ns)")
52
+ return {"tenant": tenant, "env": env}
53
+
54
+
55
+ def _active_resources(names: list) -> list:
56
+ """归档表过滤(与 store-api spec/01-routing.md 同规则):`XxxDeleted` 且 `Xxx` 在列表中 ⇒ 归档表。"""
57
+ s = set(names)
58
+ return [n for n in names if not (n.endswith("Deleted") and n[: -len("Deleted")] in s)]
59
+
60
+
61
+ def _assemble(store: Any, skins: dict) -> tuple[Any, int]:
62
+ """装配 HTTP 侧(REST + GraphQL)→ `(FastAPI app, 暴露资源数)`。
63
+
64
+ 每次调用都遍历 `store.list()` 生成路由 → 反映当下注册表(reload 的语义来源)。
65
+ """
66
+ fastapi = _optional_import("fastapi", "fastapi")
67
+ app = fastapi.FastAPI(title="store-gateway")
68
+
69
+ if skins["rest"].get("enabled"):
70
+ rest_cfg = dict(skins["rest"])
71
+ rest_prefix = rest_cfg.pop("prefix", "/api") or "" # prefix 由 gateway 按 mount 应用(spec/00 规则 4)
72
+ mod = _optional_import("store_api_py", "store-api-py")
73
+ rest_cfg.pop("enabled", None)
74
+ # 皮肤路由建在根,gateway 按前缀 mount(保持既有 py 行为,前缀可配)
75
+ app.mount(rest_prefix, mod.create_app(store, prefix="", **rest_cfg))
76
+
77
+ if skins["graphql"].get("enabled"):
78
+ gql_cfg = dict(skins["graphql"])
79
+ gql_path = gql_cfg.pop("path", "/graphql")
80
+ gql_cfg.pop("enabled", None)
81
+ mod = _optional_import("store_graphql", "store-graphql-py")
82
+ if inspect.iscoroutinefunction(mod.create_app):
83
+ raise TypeError("store_graphql.create_app 不应为 async")
84
+ app.mount("/", mod.create_app(store, path=gql_path, **gql_cfg))
85
+
86
+ names = skins["rest"].get("resources") or store.list()
87
+ return app, len(_active_resources(names))
88
+
89
+
90
+ class ReloadUnsupportedError(RuntimeError):
91
+ """reload 已配置却无宿主重建能力(显式失败,禁静默失守)"""
92
+
93
+ code = "ERR_RELOAD_UNSUPPORTED"
94
+
95
+
96
+ async def _hydrate(store: Any, reload_cfg: dict | None) -> None:
97
+ """reload 前的注册表重建(D1 闭环桥):按 ns 从持久化定义 restore 到本进程注册表。
98
+
99
+ 未配置 reload → 空操作;配置了但 store 无 `restore_defs`/`restoreDefs` 能力 → 显式报错
100
+ (禁静默:配置了要重建却重建不了,必须让 reload 失败而非假装成功)。
101
+ """
102
+ if not reload_cfg:
103
+ return
104
+ fn = getattr(store, "restore_defs", None) or getattr(store, "restoreDefs", None)
105
+ if not callable(fn):
106
+ raise ReloadUnsupportedError(
107
+ "ERR_RELOAD_UNSUPPORTED:opts.reload 已配置,但 store 未提供 restore_defs/restoreDefs"
108
+ "(无法从持久化定义重建注册表)"
109
+ )
110
+ res = fn({"tenant": reload_cfg["tenant"], "env": reload_cfg["env"]})
111
+ if inspect.isawaitable(res):
112
+ await res
113
+
114
+
115
+ class _Dispatch:
116
+ """稳定 ASGI 入口:http 委托 `state['app']`;`POST /-/reload` 自身承接(不进在途快照)。"""
117
+
118
+ def __init__(self, state: dict, reload_fn) -> None:
119
+ self._state = state
120
+ self._reload = reload_fn # async () -> int(Gateway.reload)
121
+
122
+ async def __call__(self, scope, receive, send) -> None:
123
+ if scope["type"] == "http" and scope["method"] == "POST" and scope["path"] == "/-/reload":
124
+ await self._handle_reload(send)
125
+ return
126
+ await self._state["app"](scope, receive, send)
127
+
128
+ async def _handle_reload(self, send) -> None:
129
+ try:
130
+ resources = await self._reload()
131
+ status = 200
132
+ body = {"data": {"reloaded": True, "resources": resources}}
133
+ except Exception as e: # noqa: BLE001 — reload 失败显式 500,禁静默
134
+ status = 500
135
+ body = {"error": {"code": getattr(e, "code", None) or "ERR_RELOAD_FAILED",
136
+ "message": str(e) or None}}
137
+ payload = json.dumps(body).encode("utf-8")
138
+ await send({"type": "http.response.start", "status": status,
139
+ "headers": [(b"content-type", b"application/json; charset=utf-8")]})
140
+ await send({"type": "http.response.body", "body": payload})
141
+
142
+
143
+ class Gateway:
144
+ """承载句柄:`.app`(稳定 ASGI dispatch,REST+GraphQL 共享)/ `.grpc` / `.run` / `.reload` / `.stop`。"""
145
+
146
+ def __init__(self, state: dict, grpc: Any, rest_prefix: str,
147
+ skins: dict, store: Any, reload_cfg: dict | None) -> None:
148
+ self._state = state
149
+ self._skins = skins
150
+ self._store = store
151
+ self._reload_cfg = reload_cfg
152
+ self._reload_lock = asyncio.Lock() # 重装配串行化(互斥)
153
+ self.grpc = grpc
154
+ self._rest_prefix = rest_prefix
155
+ self.app: Any = None # 由 build() 赋为 _Dispatch
156
+
157
+ def run(self, host: str = "127.0.0.1", port: int = 3000) -> None:
158
+ """uvicorn 阻塞承载 HTTP(gRPC server 已在 build 时 start)。"""
159
+ import uvicorn
160
+
161
+ uvicorn.run(self.app, host=host, port=port, log_level="warning")
162
+
163
+ async def reload(self) -> int:
164
+ """原子重装配:先在锁内 hydrate(失败同样不半替换),构建成功才替换在途 app。"""
165
+ async with self._reload_lock:
166
+ await _hydrate(self._store, self._reload_cfg)
167
+ app, resources = _assemble(self._store, self._skins) # 失败向上抛(调用方映射 500)
168
+ self._state["app"] = app
169
+ return resources
170
+
171
+ def stop(self) -> None:
172
+ if self.grpc is not None:
173
+ self.grpc.stop(None)
174
+
175
+
176
+ def build(store: Any, **opts: Any) -> Gateway:
177
+ """spec/00:读配置 → 组合三皮肤 → Gateway。
178
+
179
+ - rest: { enabled, prefix='/api', **store-api-py 选项 }
180
+ - graphql: { enabled, path='/graphql', **store-graphql-py 选项 }
181
+ - grpc: { enabled, host='127.0.0.1', port=50051, **store-grpc-py 选项 }
182
+ - reload: { tenant, env }(可选;reload 前按 ns 调 store.restore_defs 重建注册表)
183
+ """
184
+ skins = _read_skins(opts)
185
+ reload_cfg = _read_reload(opts)
186
+
187
+ app, _ = _assemble(store, skins)
188
+ state = {"app": app}
189
+
190
+ grpc_handle = None
191
+ if skins["grpc"].get("enabled"):
192
+ grpc_cfg = dict(skins["grpc"])
193
+ grpc_cfg.pop("enabled", None)
194
+ host = grpc_cfg.pop("host", "127.0.0.1")
195
+ port = grpc_cfg.pop("port", 50051)
196
+ mod = _optional_import("store_grpc", "store-grpc-py")
197
+ grpc_handle = mod.create_server(store, host=host, port=port, **grpc_cfg)
198
+
199
+ gw = Gateway(state, grpc_handle, skins["rest"].get("prefix") or "/api", skins, store, reload_cfg)
200
+ gw.app = _Dispatch(state, gw.reload)
201
+ return gw
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: store-gateway-py
3
+ Version: 0.1.1
4
+ Summary: Optional combiner for the three py-store API skins (REST store-api-py / GraphQL store-graphql-py / gRPC store-grpc-py): config-driven per-skin enable switches
5
+ License: MIT
6
+ Keywords: py-store,storepy,gateway,rest,graphql,grpc,pluggable
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: fastapi>=0.110
10
+ Requires-Dist: uvicorn>=0.29
11
+ Provides-Extra: rest
12
+ Requires-Dist: store-api-py; extra == "rest"
13
+ Provides-Extra: graphql
14
+ Requires-Dist: store-graphql-py; extra == "graphql"
15
+ Provides-Extra: grpc
16
+ Requires-Dist: store-grpc-py; extra == "grpc"
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8; extra == "dev"
19
+ Requires-Dist: httpx>=0.27; extra == "dev"
20
+
21
+ # store-gateway-py
22
+
23
+ py-store 三层 API 皮肤(REST / GraphQL / gRPC)的可选组合器(`store-gateway-py`)。规范与用法见仓库根 README 与 `../spec/`。
24
+
25
+ 本地开发时三皮肤以各自仓库 editable 安装:
26
+
27
+ ```bash
28
+ pip install -e ../store-api/py ../store-graphql/py ../store-grpc/py
29
+ ```
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/store_gateway/__init__.py
4
+ src/store_gateway_py.egg-info/PKG-INFO
5
+ src/store_gateway_py.egg-info/SOURCES.txt
6
+ src/store_gateway_py.egg-info/dependency_links.txt
7
+ src/store_gateway_py.egg-info/requires.txt
8
+ src/store_gateway_py.egg-info/top_level.txt
9
+ tests/test_gateway.py
@@ -0,0 +1,15 @@
1
+ fastapi>=0.110
2
+ uvicorn>=0.29
3
+
4
+ [dev]
5
+ pytest>=8
6
+ httpx>=0.27
7
+
8
+ [graphql]
9
+ store-graphql-py
10
+
11
+ [grpc]
12
+ store-grpc-py
13
+
14
+ [rest]
15
+ store-api-py
@@ -0,0 +1,230 @@
1
+ """gateway 冒烟测试 — 三层皮肤插拔组合(mock store;HTTP 走 TestClient,gRPC 走真实端口)。
2
+
3
+ 语义依据:../../spec/00-protocol.md。
4
+ """
5
+
6
+ import json
7
+ import re
8
+
9
+ import pytest
10
+
11
+ from store_gateway import build
12
+
13
+ pytest.importorskip("store_api_py")
14
+ pytest.importorskip("store_graphql")
15
+ pytest.importorskip("store_grpc")
16
+ pytest.importorskip("httpx")
17
+ grpc = pytest.importorskip("grpc")
18
+
19
+ from fastapi.testclient import TestClient # noqa: E402
20
+
21
+ DEFN = {
22
+ "name": "User",
23
+ "description": "用户表",
24
+ "fields": {
25
+ "_id": {"type": "string"},
26
+ "name": {"type": "string"},
27
+ "age": {"type": "int"},
28
+ },
29
+ }
30
+
31
+
32
+ class PermissionErrorMock(Exception):
33
+ pass
34
+
35
+
36
+ def make_mock_store():
37
+ rows = []
38
+
39
+ class Store:
40
+ PermissionError = PermissionErrorMock
41
+
42
+ def __init__(self):
43
+ self.names = ["User"] # 可变列表:reload 用例据此调整暴露资源
44
+ self.restore_calls = [] # 记录 reload 前 hydrate 的调用参数
45
+
46
+ def list(self):
47
+ return self.names
48
+
49
+ get = staticmethod(lambda name: DEFN)
50
+
51
+ async def restore_defs(self, opts):
52
+ self.restore_calls.append(opts)
53
+
54
+ @staticmethod
55
+ async def query(gql, params=None):
56
+ cond = (params or {}).get("c0")
57
+ return [r for r in rows if not cond or all(r.get(k) == v for k, v in cond.items())]
58
+
59
+ @staticmethod
60
+ async def query_one(gql, params=None):
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
+ async def insert(name, data):
66
+ doc = {"_id": f"u{len(rows) + 1}", **data}
67
+ rows.append(doc)
68
+ return doc
69
+
70
+ @staticmethod
71
+ async 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
+ async 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
+ async def set_context(ctx):
86
+ pass
87
+
88
+ return Store()
89
+
90
+
91
+ def test_no_skin_raises():
92
+ store = make_mock_store()
93
+ with pytest.raises(ValueError, match="ERR_NO_SKIN"):
94
+ build(store)
95
+
96
+
97
+ def test_rest_only():
98
+ store = make_mock_store()
99
+ gw = build(store, rest={"enabled": True, "prefix": "/api"})
100
+ try:
101
+ client = TestClient(gw.app)
102
+ r = client.get("/api/User")
103
+ assert r.status_code == 200
104
+ assert r.json() == {"data": []}
105
+ finally:
106
+ if gw.grpc is not None:
107
+ gw.stop()
108
+
109
+
110
+ def test_graphql_only():
111
+ store = make_mock_store()
112
+ gw = build(store, graphql={"enabled": True})
113
+ try:
114
+ client = TestClient(gw.app)
115
+ r = client.post("/graphql", json={"query": "{ __typename }"})
116
+ assert r.status_code == 200
117
+ assert r.json()["data"]["__typename"] == "Query"
118
+ finally:
119
+ if gw.grpc is not None:
120
+ gw.stop()
121
+
122
+
123
+ def test_all_three_skins():
124
+ store = make_mock_store()
125
+ gw = build(
126
+ store,
127
+ rest={"enabled": True, "prefix": "/api"},
128
+ graphql={"enabled": True, "path": "/gql"},
129
+ grpc={"enabled": True, "port": 0},
130
+ )
131
+ try:
132
+ client = TestClient(gw.app)
133
+ # REST:写入
134
+ r = client.post("/api/User", json={"name": "alice", "age": 30})
135
+ assert r.status_code == 201, r.text
136
+ # GraphQL:读回(自定义 path)
137
+ r = client.post("/gql", json={"query": "{ list_User { name age } }"})
138
+ assert r.status_code == 200, r.text
139
+ assert r.json()["data"]["list_User"] == [{"name": "alice", "age": 30}]
140
+
141
+ # gRPC:ListUser 读回同一份数据(同一 store 实例)
142
+ pb = gw.grpc.pb
143
+ channel = grpc.insecure_channel(f"127.0.0.1:{gw.grpc.port}")
144
+ try:
145
+ unary = channel.unary_unary(
146
+ "/store.v0.User/ListUser",
147
+ request_serializer=pb.ListRequest.SerializeToString,
148
+ response_deserializer=pb.StoreReply.FromString,
149
+ )
150
+ reply = unary(pb.ListRequest(q=" { name, age }"), timeout=10)
151
+ assert json.loads(reply.data_json) == [{"_id": "u1", "name": "alice", "age": 30}]
152
+ finally:
153
+ channel.close()
154
+ finally:
155
+ gw.stop()
156
+
157
+
158
+ def test_missing_skin_package_raises(monkeypatch):
159
+ store = make_mock_store()
160
+ import importlib
161
+
162
+ real_import_module = importlib.import_module
163
+
164
+ def fake_import_module(name, *args, **kwargs):
165
+ if name == "store_api_py":
166
+ raise ImportError("No module named 'store_api_py'")
167
+ return real_import_module(name, *args, **kwargs)
168
+
169
+ monkeypatch.setattr(importlib, "import_module", fake_import_module)
170
+ with pytest.raises(ImportError, match="store_api_py"):
171
+ build(store, rest={"enabled": True})
172
+
173
+
174
+ # ─── 运行期重装配(reload;对齐 node store-gateway) ───────────────────
175
+
176
+
177
+ def test_reload_reassembles():
178
+ """POST /-/reload 重装配 HTTP 侧:资源数反映当下注册表,新资源路由可访问。"""
179
+ store = make_mock_store()
180
+ gw = build(store, rest={"enabled": True, "prefix": "/api"})
181
+ try:
182
+ client = TestClient(gw.app)
183
+ r = client.post("/-/reload")
184
+ assert r.status_code == 200
185
+ assert r.json() == {"data": {"reloaded": True, "resources": 1}}
186
+
187
+ store.names.append("Order") # 运行期新注册(快照外)→ 仅经 reload 进入路由
188
+ r2 = client.post("/-/reload")
189
+ assert r2.status_code == 200
190
+ assert r2.json()["data"]["resources"] == 2 # 递增
191
+ assert client.get("/api/Order").status_code == 200
192
+ finally:
193
+ if gw.grpc is not None:
194
+ gw.stop()
195
+
196
+
197
+ def test_reload_hydrates_registry():
198
+ """配 reload={tenant,env} → 重装配前按 ns 调 store.restore_defs(D1 闭环桥)。"""
199
+ store = make_mock_store()
200
+ gw = build(store, rest={"enabled": True}, reload={"tenant": "t1", "env": "dev"})
201
+ try:
202
+ client = TestClient(gw.app)
203
+ r = client.post("/-/reload")
204
+ assert r.status_code == 200
205
+ assert store.restore_calls == [{"tenant": "t1", "env": "dev"}]
206
+ finally:
207
+ if gw.grpc is not None:
208
+ gw.stop()
209
+
210
+
211
+ def test_reload_unsupported_store():
212
+ """配 reload 但 store 无重建能力 → 500 ERR_RELOAD_UNSUPPORTED(禁静默)。"""
213
+ store = make_mock_store()
214
+ store.restore_defs = None # 显式移除重建能力
215
+ gw = build(store, rest={"enabled": True}, reload={"tenant": "t1", "env": "dev"})
216
+ try:
217
+ client = TestClient(gw.app)
218
+ r = client.post("/-/reload")
219
+ assert r.status_code == 500
220
+ assert r.json()["error"]["code"] == "ERR_RELOAD_UNSUPPORTED"
221
+ finally:
222
+ if gw.grpc is not None:
223
+ gw.stop()
224
+
225
+
226
+ def test_reload_missing_ns_raises():
227
+ """reload 缺 tenant/env → 构造期 fail-fast(ERR_RELOAD_CONFIG)。"""
228
+ store = make_mock_store()
229
+ with pytest.raises(ValueError, match="ERR_RELOAD_CONFIG"):
230
+ build(store, rest={"enabled": True}, reload={"tenant": "t1"})