veil-api-kit 0.10.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
veil_kit/di.py ADDED
@@ -0,0 +1,362 @@
1
+ """A dependency injection container on top of ``veil.Depends``.
2
+
3
+ .. code-block:: python
4
+
5
+ from veil_kit.di import Container, Inject
6
+
7
+ container = Container()
8
+ container.singleton(Settings, lambda: Settings.from_env())
9
+ container.scoped(Session, make_session) # once per request; make_session may yield
10
+ container.transient(Clock, Clock)
11
+
12
+ @container.provider(Repo, scope="scoped")
13
+ def repo(session: Session, settings: Settings): # parameters of registered types are injected
14
+ return Repo(session, settings)
15
+
16
+ @app.get("/items")
17
+ def items(repo: Repo = Inject(Repo)):
18
+ return repo.list()
19
+
20
+ with container.override(Repo, FakeRepo()): # tests
21
+ ...
22
+
23
+ A provider is resolved through ``veil.Depends``: its parameters may also be request parameters (``Query``,
24
+ ``Header``, ``request: veil.Request``, other ``Depends``) and generators clean up after the request. ``singleton``
25
+ values are created once per process (thread-safe), ``scoped`` once per request, ``transient`` on every use.
26
+ Registrations may come after the routes that use them, until the server starts.
27
+ """
28
+
29
+ # F-268: 解決は veil の Depends の木(登録時に組み立て、要求ごとは決まった順に呼ぶ)。差し替えは提供者の入れ替え。
30
+
31
+ import contextlib
32
+ import inspect
33
+ import threading
34
+
35
+ import veil
36
+
37
+ __all__ = ["Container", "Inject", "SINGLETON", "SCOPED", "TRANSIENT"]
38
+
39
+ SINGLETON, SCOPED, TRANSIENT = "singleton", "scoped", "transient"
40
+
41
+ _current = None
42
+
43
+
44
+ class _Registration:
45
+ __slots__ = ("key", "factory", "scope", "value", "lock", "has_value", "resolver")
46
+
47
+ def __init__(self, key, factory, scope):
48
+ self.key = key
49
+ self.factory = factory
50
+ self.scope = scope
51
+ self.value = None
52
+ self.has_value = False
53
+ self.lock = threading.Lock()
54
+ self.resolver = None
55
+
56
+
57
+ class Container:
58
+ """Providers by type (or any hashable key)."""
59
+
60
+ def __init__(self):
61
+ """Create an empty container."""
62
+ self._regs = {}
63
+ self._overrides = {}
64
+ self._stubs = {}
65
+ global _current
66
+ _current = self
67
+
68
+ # ---- 登録 ----
69
+
70
+ def register(self, key, factory=None, scope=SCOPED):
71
+ """Register ``factory`` (a callable, or a value for ``singleton``) for ``key``; ``factory`` defaults to ``key``."""
72
+ if scope not in (SINGLETON, SCOPED, TRANSIENT):
73
+ raise ValueError("scope must be singleton, scoped or transient")
74
+ if factory is None:
75
+ factory = key
76
+ if not callable(factory):
77
+ value = factory
78
+ reg = _Registration(key, None, SINGLETON)
79
+ reg.value, reg.has_value = value, True
80
+ else:
81
+ reg = _Registration(key, factory, scope)
82
+ self._regs[key] = reg
83
+ self._stubs.pop(key, None)
84
+ return self
85
+
86
+ def singleton(self, key, factory=None):
87
+ """Register a provider created once per process (or a ready value)."""
88
+ return self.register(key, factory, SINGLETON)
89
+
90
+ def scoped(self, key, factory=None):
91
+ """Register a provider created once per request."""
92
+ return self.register(key, factory, SCOPED)
93
+
94
+ def transient(self, key, factory=None):
95
+ """Register a provider created on every use."""
96
+ return self.register(key, factory, TRANSIENT)
97
+
98
+ def value(self, key, value):
99
+ """Register an existing value."""
100
+ reg = _Registration(key, None, SINGLETON)
101
+ reg.value, reg.has_value = value, True
102
+ self._regs[key] = reg
103
+ self._stubs.pop(key, None)
104
+ return self
105
+
106
+ def provider(self, key, scope=SCOPED):
107
+ """Decorator form of :meth:`register`."""
108
+
109
+ def deco(f):
110
+ self.register(key, f, scope)
111
+ return f
112
+
113
+ return deco
114
+
115
+ # ---- 解決 ----
116
+
117
+ def depends(self, key):
118
+ """The ``veil.Depends`` marker for ``key`` (``Inject(key)`` uses the current container)."""
119
+ reg = self._regs.get(key)
120
+ scope = reg.scope if reg is not None else SCOPED
121
+ return veil.Depends(self._resolver(key), use_cache=scope != TRANSIENT)
122
+
123
+ def _resolver(self, key):
124
+ stub = self._stubs.get(key)
125
+ if stub is not None:
126
+ return stub
127
+ reg = self._regs.get(key)
128
+ if reg is None:
129
+ # まだ登録されていない: 呼ばれたとき(起動後)に解決する(ジェネレータの提供者は要求の後に閉じる)
130
+ container = self
131
+
132
+ def late(request: veil.Request):
133
+ o = container._overrides.get(key, _MISSING)
134
+ if o is not _MISSING:
135
+ yield o
136
+ return
137
+ reg = container._regs.get(key)
138
+ if reg is None:
139
+ raise LookupError("no provider registered for %r" % (key,))
140
+ if reg.has_value or reg.scope == SINGLETON:
141
+ yield container.get(key)
142
+ return
143
+ v = reg.factory(**container._registered_kwargs(reg.factory))
144
+ if inspect.isgenerator(v):
145
+ yield from _once(v)
146
+ return
147
+ if inspect.isawaitable(v):
148
+ from veil import _run_coro
149
+
150
+ v = _run_coro(v)
151
+ yield v
152
+
153
+ late._veil_inline = True
154
+ stub = late
155
+ else:
156
+ stub = self._make_resolver(reg)
157
+ self._stubs[key] = stub
158
+ return stub
159
+
160
+ def _make_resolver(self, reg):
161
+ container = self
162
+ key = reg.key
163
+ if reg.has_value:
164
+ def value_resolver():
165
+ o = container._overrides.get(key, _MISSING)
166
+ return reg.value if o is _MISSING else o
167
+
168
+ value_resolver._veil_inline = True
169
+ return value_resolver
170
+ factory = reg.factory
171
+ sig = _injected_signature(self, factory)
172
+ regs = self._regs
173
+ kind = "gen" if inspect.isgeneratorfunction(factory) else (
174
+ "agen" if inspect.isasyncgenfunction(factory) else (
175
+ "async" if inspect.iscoroutinefunction(factory) else "sync"))
176
+
177
+ if reg.scope == SINGLETON:
178
+ def resolver(**kw):
179
+ o = container._overrides.get(key, _MISSING)
180
+ if o is not _MISSING:
181
+ return o
182
+ cur = regs[key]
183
+ if not cur.has_value:
184
+ with cur.lock:
185
+ if not cur.has_value:
186
+ v = cur.factory(**kw)
187
+ if inspect.isawaitable(v):
188
+ from veil import _run_coro
189
+
190
+ v = _run_coro(v)
191
+ cur.value, cur.has_value = v, True
192
+ return cur.value
193
+ elif kind == "gen":
194
+ def resolver(**kw):
195
+ o = container._overrides.get(key, _MISSING)
196
+ if o is not _MISSING:
197
+ yield o
198
+ return
199
+ yield from regs[key].factory(**kw)
200
+ elif kind == "agen":
201
+ async def resolver(**kw):
202
+ o = container._overrides.get(key, _MISSING)
203
+ if o is not _MISSING:
204
+ yield o
205
+ return
206
+ async for v in regs[key].factory(**kw):
207
+ yield v
208
+ elif kind == "async":
209
+ async def resolver(**kw):
210
+ o = container._overrides.get(key, _MISSING)
211
+ if o is not _MISSING:
212
+ return o
213
+ return await regs[key].factory(**kw)
214
+ else:
215
+ def resolver(**kw):
216
+ o = container._overrides.get(key, _MISSING)
217
+ if o is not _MISSING:
218
+ return o
219
+ return regs[key].factory(**kw)
220
+
221
+ resolver.__signature__ = sig
222
+ resolver.__annotations__ = {p.name: p.annotation for p in sig.parameters.values()
223
+ if p.annotation is not inspect.Parameter.empty}
224
+ resolver.__name__ = "provide_%s" % getattr(key, "__name__", "value")
225
+ resolver.__qualname__ = resolver.__name__
226
+ resolver.__module__ = getattr(factory, "__module__", __name__)
227
+ if getattr(factory, "_veil_inline", False):
228
+ resolver._veil_inline = True
229
+ return resolver
230
+
231
+ def get(self, key):
232
+ """Resolve ``key`` outside a request (singletons and providers without request parameters; a generator
233
+ provider gives its first value and its code after ``yield`` is not run)."""
234
+ o = self._overrides.get(key, _MISSING)
235
+ if o is not _MISSING:
236
+ return o
237
+ reg = self._regs.get(key)
238
+ if reg is None:
239
+ raise LookupError("no provider registered for %r" % (key,))
240
+ if reg.has_value:
241
+ return reg.value
242
+ kwargs = self._registered_kwargs(reg.factory)
243
+ if reg.scope == SINGLETON:
244
+ with reg.lock:
245
+ if not reg.has_value:
246
+ reg.value, reg.has_value = reg.factory(**kwargs), True
247
+ return reg.value
248
+ v = reg.factory(**kwargs)
249
+ if inspect.isgenerator(v):
250
+ # 要求の外: 最初の値を返す(yield の後は実行しない)
251
+ return next(v)
252
+ if inspect.isawaitable(v):
253
+ from veil import _run_coro
254
+
255
+ v = _run_coro(v)
256
+ return v
257
+
258
+ def _registered_kwargs(self, factory):
259
+ # 登録された型の引数だけを解決する(要求の引数は無い前提)
260
+ kwargs = {}
261
+ for p in _injected_signature(self, factory).parameters.values():
262
+ d = p.default
263
+ if isinstance(d, veil.Depends) and d.dependency in self._stubs.values():
264
+ kwargs[p.name] = self.get(_key_of(self, d.dependency))
265
+ return kwargs
266
+
267
+ # ---- 差し替え ----
268
+
269
+ @contextlib.contextmanager
270
+ def override(self, key, value):
271
+ """Use ``value`` for ``key`` inside the ``with`` block (tests); requests in progress may see either."""
272
+ old = self._overrides.get(key, _MISSING)
273
+ self._overrides[key] = value
274
+ try:
275
+ yield value
276
+ finally:
277
+ if old is _MISSING:
278
+ self._overrides.pop(key, None)
279
+ else:
280
+ self._overrides[key] = old
281
+
282
+ def replace(self, key, factory, app=None):
283
+ """Replace the provider of ``key`` while the server runs (new requests use it).
284
+
285
+ A provider with the same parameters is swapped in place. One with other parameters needs ``app``: the routes
286
+ are rebuilt through ``app.dependency_overrides``.
287
+ """
288
+ reg = self._regs.get(key)
289
+ if reg is None:
290
+ self.register(key, factory)
291
+ return
292
+ old = self._stubs.get(key)
293
+ if app is not None and old is not None:
294
+ new = self._make_resolver(_Registration(key, factory, reg.scope))
295
+ app.dependency_overrides[old] = new
296
+ return
297
+ if old is not None and _injected_signature(self, factory) != _injected_signature(self, reg.factory):
298
+ raise ValueError("the new provider has other parameters; pass app= to rebuild the routes")
299
+ reg.factory = factory
300
+ reg.has_value = False
301
+ reg.value = None
302
+
303
+ def reset(self, key):
304
+ """Forget the cached singleton value of ``key``."""
305
+ reg = self._regs.get(key)
306
+ if reg is not None and reg.factory is not None:
307
+ reg.has_value = False
308
+ reg.value = None
309
+
310
+
311
+ _MISSING = object()
312
+
313
+
314
+ def _once(gen):
315
+ # ジェネレータの提供者の最初の値を渡し、要求の後に残りを実行する
316
+ v = next(gen)
317
+ try:
318
+ yield v
319
+ finally:
320
+ try:
321
+ next(gen)
322
+ except StopIteration:
323
+ pass
324
+
325
+
326
+ def _key_of(container, resolver):
327
+ for k, v in container._stubs.items():
328
+ if v is resolver:
329
+ return k
330
+ raise LookupError(resolver)
331
+
332
+
333
+ def _injected_signature(container, factory):
334
+ """The signature of ``factory`` with the parameters of registered types turned into ``Depends``."""
335
+ target = factory.__init__ if inspect.isclass(factory) else factory
336
+ try:
337
+ sig = inspect.signature(factory)
338
+ except (TypeError, ValueError):
339
+ return inspect.Signature()
340
+ try:
341
+ import typing
342
+
343
+ hints = typing.get_type_hints(target)
344
+ except Exception: # noqa: BLE001
345
+ hints = {}
346
+ params = []
347
+ for p in sig.parameters.values():
348
+ ann = hints.get(p.name, p.annotation)
349
+ if p.default is inspect.Parameter.empty and ann in container._regs:
350
+ p = p.replace(default=container.depends(ann), annotation=ann)
351
+ elif ann is not inspect.Parameter.empty:
352
+ p = p.replace(annotation=ann)
353
+ params.append(p)
354
+ return sig.replace(parameters=params, return_annotation=inspect.Signature.empty)
355
+
356
+
357
+ def Inject(key, container=None): # noqa: N802
358
+ """``param: T = Inject(T)``: the ``veil.Depends`` marker for ``key`` in ``container`` (the last created by default)."""
359
+ c = container or _current
360
+ if c is None:
361
+ raise RuntimeError("create a Container before using Inject()")
362
+ return c.depends(key)
veil_kit/graphql.py ADDED
@@ -0,0 +1,215 @@
1
+ """GraphQL with strawberry (``pip install "veil-api-kit[graphql]"``).
2
+
3
+ .. code-block:: python
4
+
5
+ import strawberry
6
+ from veil_kit.graphql import mount
7
+
8
+ schema = strawberry.Schema(query=Query, mutation=Mutation, subscription=Subscription)
9
+ mount(app, "/graphql", schema)
10
+
11
+ One path serves ``POST`` (JSON, batches too) and ``GET`` (query string), GraphQL over SSE for subscriptions
12
+ (``Accept: text/event-stream``), WebSocket subscriptions (``graphql-transport-ws`` and the older ``graphql-ws``) and
13
+ GraphiQL for a browser ``GET`` (``graphiql=False`` turns it off; the page loads its scripts from a CDN).
14
+ ``context_getter(request)`` builds the context passed to resolvers (default: ``{"request": request}``).
15
+ """
16
+
17
+ # F-268: examples/api/python-graphql(F-210)を取り込み、WebSocket(F-263)のサブスクリプションを足した
18
+
19
+ import asyncio
20
+ import json
21
+
22
+ import veil
23
+
24
+ __all__ = ["mount", "GraphQLRoutes"]
25
+
26
+ _GRAPHQL_RESPONSE = "application/graphql-response+json"
27
+
28
+
29
+ def _result(r):
30
+ out = {"data": r.data}
31
+ if r.errors:
32
+ out["errors"] = [e.formatted if hasattr(e, "formatted") else {"message": str(e)} for e in r.errors]
33
+ if getattr(r, "extensions", None):
34
+ out["extensions"] = r.extensions
35
+ return out
36
+
37
+
38
+ def _graphiql_html(path):
39
+ try:
40
+ from strawberry.http.ide import get_graphql_ide_html # noqa: PLC0415
41
+
42
+ return get_graphql_ide_html(graphql_ide="graphiql")
43
+ except ImportError:
44
+ pass
45
+ try:
46
+ from strawberry.utils.graphiql import get_graphiql_html # noqa: PLC0415
47
+
48
+ return get_graphiql_html()
49
+ except ImportError:
50
+ pass
51
+ return (
52
+ "<!doctype html><html><head><title>GraphiQL</title>"
53
+ '<link rel="stylesheet" href="https://unpkg.com/graphiql@3/graphiql.min.css"></head>'
54
+ '<body style="margin:0"><div id="g" style="height:100vh"></div>'
55
+ '<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>'
56
+ '<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>'
57
+ '<script src="https://unpkg.com/graphiql@3/graphiql.min.js"></script>'
58
+ "<script>ReactDOM.createRoot(document.getElementById('g')).render(React.createElement(GraphiQL,"
59
+ "{fetcher:GraphiQL.createFetcher({url:%s})}))</script></body></html>" % json.dumps(path)
60
+ )
61
+
62
+
63
+ class GraphQLRoutes:
64
+ """The routes of one schema (created by :func:`mount`)."""
65
+
66
+ def __init__(self, schema, path, graphiql=True, context_getter=None, keep_alive=None):
67
+ self.schema = schema
68
+ self.path = path
69
+ self.graphiql = graphiql
70
+ self.context_getter = context_getter or (lambda request: {"request": request})
71
+ self.keep_alive = keep_alive
72
+
73
+ async def _execute(self, body, request):
74
+ ctx = self.context_getter(request)
75
+ if isinstance(ctx, asyncio.Future) or asyncio.iscoroutine(ctx):
76
+ ctx = await ctx
77
+ r = await self.schema.execute(body.get("query") or "", variable_values=body.get("variables"),
78
+ operation_name=body.get("operationName"), context_value=ctx)
79
+ return _result(r)
80
+
81
+ async def _subscribe(self, body, request):
82
+ ctx = self.context_getter(request)
83
+ if asyncio.iscoroutine(ctx):
84
+ ctx = await ctx
85
+ r = await self.schema.subscribe(body.get("query") or "", variable_values=body.get("variables"),
86
+ operation_name=body.get("operationName"), context_value=ctx)
87
+ if hasattr(r, "__aiter__"):
88
+ async for x in r:
89
+ yield _result(x)
90
+ else:
91
+ yield _result(r)
92
+
93
+ async def http(self, request: veil.Request):
94
+ """``GET`` / ``POST`` on the GraphQL path."""
95
+ accept = request.headers.get("accept") or ""
96
+ if request.method in ("GET", "HEAD"):
97
+ q = request.query_params
98
+ if not q.get("query"):
99
+ if self.graphiql and "text/html" in accept:
100
+ return veil.HTMLResponse(_graphiql_html(self.path))
101
+ raise veil.HTTPException(400, "missing query")
102
+ try:
103
+ variables = json.loads(q["variables"]) if q.get("variables") else None
104
+ except ValueError:
105
+ raise veil.HTTPException(400, "variables is not valid JSON")
106
+ body = {"query": q.get("query"), "variables": variables, "operationName": q.get("operationName")}
107
+ else:
108
+ try:
109
+ body = json.loads(request.body or b"null")
110
+ except ValueError:
111
+ raise veil.HTTPException(400, "the body is not valid JSON")
112
+ if "text/event-stream" in accept:
113
+ if not isinstance(body, dict):
114
+ raise veil.HTTPException(400, "subscriptions over SSE take one operation")
115
+
116
+ async def events():
117
+ async for r in self._subscribe(body, request):
118
+ yield {"event": "next", "data": r}
119
+ yield {"event": "complete", "data": ""}
120
+
121
+ return veil.EventSourceResponse(events())
122
+ if isinstance(body, list):
123
+ out = [await self._execute(b, request) for b in body if isinstance(b, dict)]
124
+ elif isinstance(body, dict):
125
+ out = await self._execute(body, request)
126
+ else:
127
+ raise veil.HTTPException(400, "a GraphQL request is a JSON object")
128
+ media = _GRAPHQL_RESPONSE if _GRAPHQL_RESPONSE in accept else "application/json"
129
+ return veil.Response(json.dumps(out, separators=(",", ":")), 200, media_type=media)
130
+
131
+ async def websocket(self, websocket: veil.WebSocket):
132
+ """Subscriptions over WebSocket (``graphql-transport-ws`` and ``graphql-ws``)."""
133
+ offered = websocket.subprotocols
134
+ proto = next((p for p in ("graphql-transport-ws", "graphql-ws") if p in offered), None)
135
+ if proto is None:
136
+ await websocket.close(4406)
137
+ return
138
+ await websocket.accept(subprotocol=proto)
139
+ legacy = proto == "graphql-ws"
140
+ ops = {}
141
+ acked = False
142
+
143
+ async def run(op_id, payload):
144
+ try:
145
+ async for r in self._subscribe(payload, websocket):
146
+ await websocket.send_json({"type": "data" if legacy else "next", "id": op_id, "payload": r})
147
+ await websocket.send_json({"type": "complete", "id": op_id})
148
+ except asyncio.CancelledError:
149
+ raise
150
+ except Exception as e: # noqa: BLE001
151
+ await websocket.send_json({"type": "error", "id": op_id, "payload": [{"message": str(e)}]})
152
+ finally:
153
+ ops.pop(op_id, None)
154
+
155
+ try:
156
+ while True:
157
+ try:
158
+ msg = await websocket.receive_json()
159
+ except veil.WebSocketDisconnect:
160
+ break
161
+ except (ValueError, TypeError):
162
+ await websocket.close(4400, "invalid message")
163
+ break
164
+ t = msg.get("type") if isinstance(msg, dict) else None
165
+ if t == "connection_init":
166
+ if acked and not legacy:
167
+ await websocket.close(4429, "Too many initialisation requests")
168
+ break
169
+ acked = True
170
+ await websocket.send_json({"type": "connection_ack"})
171
+ elif t == "ping":
172
+ await websocket.send_json({"type": "pong", "payload": msg.get("payload")})
173
+ elif t == "pong":
174
+ pass
175
+ elif t in ("subscribe", "start"):
176
+ if not acked:
177
+ await websocket.close(4401, "Unauthorized")
178
+ break
179
+ op_id = msg.get("id")
180
+ if op_id in ops:
181
+ await websocket.close(4409, "Subscriber for %s already exists" % op_id)
182
+ break
183
+ ops[op_id] = asyncio.get_running_loop().create_task(run(op_id, msg.get("payload") or {}))
184
+ elif t in ("complete", "stop"):
185
+ task = ops.pop(msg.get("id"), None)
186
+ if task is not None:
187
+ task.cancel()
188
+ elif t == "connection_terminate":
189
+ await websocket.close(1000)
190
+ break
191
+ else:
192
+ await websocket.close(4400, "Unknown message type")
193
+ break
194
+ finally:
195
+ for task in list(ops.values()):
196
+ task.cancel()
197
+
198
+
199
+ def mount(app, path, schema, *, graphiql=True, context_getter=None, websocket=True):
200
+ """Serve ``schema`` (a ``strawberry.Schema``) at ``path`` on ``app``; returns the :class:`GraphQLRoutes`."""
201
+ routes = GraphQLRoutes(schema, path, graphiql, context_getter)
202
+
203
+ async def graphql_http(request: veil.Request):
204
+ return await routes.http(request)
205
+
206
+ graphql_http.__name__ = "graphql" + path.replace("/", "_").replace("{", "").replace("}", "")
207
+ app.route(path, methods=["GET", "POST"], tags=["graphql"], summary="GraphQL endpoint")(graphql_http)
208
+ if websocket:
209
+ async def graphql_ws(websocket: veil.WebSocket):
210
+ await routes.websocket(websocket)
211
+
212
+ graphql_ws.__name__ = graphql_http.__name__ + "_ws"
213
+ # 同じパスの GET と WebSocket は、サーバが Upgrade の有無で振り分ける
214
+ app.websocket(path, subprotocols=["graphql-transport-ws", "graphql-ws"], include_in_schema=False)(graphql_ws)
215
+ return routes