simple-grpcio 0.1.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ from collections.abc import Iterator
2
+
3
+ from google.protobuf.message import Message
4
+ from grpc import ServicerContext as Context, StatusCode as status
5
+
6
+ from .applications import SimpleGRPC
7
+ from .params import Depends
8
+
9
+
10
+ Request = Message # Requests are application-defined proto message classes, handled opaquely,
11
+ RequestIterator = Iterator[Message] # so Any is the honest annotation.
12
+
13
+ # __all__ = ["Context", "Depends", "Request", "RequestIterator", "SimpleGRPC", "status"]
@@ -0,0 +1,436 @@
1
+ from collections.abc import Callable, Iterator
2
+ from concurrent.futures import ThreadPoolExecutor
3
+ import os
4
+ from pathlib import Path
5
+ import sys
6
+ from types import ModuleType
7
+ from typing import Any, Unpack
8
+
9
+ from google.protobuf import message_factory
10
+ from google.protobuf.message import Message
11
+ import grpc
12
+ from grpc_tools import protoc
13
+
14
+ import simple_grpc.dispatch as dispatch
15
+ from simple_grpc.logging import (
16
+ LoggingConfig,
17
+ configure_logging,
18
+ logger,
19
+ style,
20
+ )
21
+ from simple_grpc.models import ProtoConfig, ServerCredentials, ServerOptions
22
+ from simple_grpc.params import INJECTABLE, Depends
23
+ import simple_grpc.utils as utils
24
+
25
+
26
+ class SimpleGRPC:
27
+ """
28
+ `SimpleGRPC` app class, the main entrypoint to use SimpleGRPC.
29
+
30
+ Handlers are attached with :meth:`rpc` and may declare any subset of the
31
+ injectable parameters (by name): ``request`` (the request message, or the
32
+ request iterator on streaming RPCs), ``request_iterator`` (alias, same
33
+ object) and ``context``.
34
+
35
+ Handlers may also declare dependencies with
36
+ ``Depends(...)`` parameter defaults. Dependencies (app-wide ones passed to
37
+ the constructor, or signature ones declared on the handler) are
38
+ plain functions resolved automatically at call time, yielding pre/post
39
+ interceptor-like behavior for generator dependencies.
40
+ """
41
+
42
+ # Parameter names a handler may declare for dependency injection.
43
+ INJECTABLE = INJECTABLE # just for compatibility
44
+
45
+ def __init__(
46
+ self,
47
+ *,
48
+ thread_pool: ThreadPoolExecutor | None = None,
49
+ raw_protos: dict[str, str] | None = None,
50
+ proto_path: str | Path | None = None,
51
+ proto_files: list[str] | None = None,
52
+ service_path: str | Path | None = None,
53
+ port: int = 50051,
54
+ dependencies: list[Depends] | None = None,
55
+ credentials: ServerCredentials | None = None,
56
+ logging_config: LoggingConfig | None = None,
57
+ **server_options: Unpack[ServerOptions],
58
+ ) -> None:
59
+ """
60
+ Store the compile config; handlers attach later via :meth:`rpc`.
61
+
62
+ Raises:
63
+ ValueError: If ``raw_protos`` is passed together with
64
+ ``proto_path`` or ``proto_files`` - provide protos either as
65
+ inline content or as files on disk, not both.
66
+
67
+ Args:
68
+ thread_pool: Executor used to serve requests. Defaults to a new
69
+ ``ThreadPoolExecutor``.
70
+ raw_protos: Proto content as inline strings, keyed by the stem to
71
+ give the generated modules (``{"calc": "syntax..."}`` ->
72
+ ``calc_pb2``). Materialized into a content-addressed dir under
73
+ ``tempfile.gettempdir()``; identical content maps to the
74
+ identical dir, so a client constructing the app with the same
75
+ content finds the generated code without coordination.
76
+ proto_path: Directory containing ``.proto`` files. ``None``
77
+ (default) resolves to the raw-proto dir in raw mode, else
78
+ ``"protos"``.
79
+ proto_files: Proto files to compile; ``None`` (default) discovers
80
+ all ``*.proto`` in ``proto_path`` in file mode, or uses the
81
+ ``raw_protos`` keys in raw mode.
82
+ service_path: Output directory for the generated ``*_pb2*.py``.
83
+ ``None`` (default) resolves to the raw-proto dir in raw mode,
84
+ else ``"grpc_services"``. An explicitly passed value wins in
85
+ both modes (the client passes the same one when rendezvousing).
86
+ port: Port the server binds with ``dev()``/``run()``.
87
+ credentials: TLS credentials; each PEM field takes a ``Path`` or
88
+ the content itself. Given, ``_start`` binds with
89
+ ``add_secure_port`` (and ``ssl_server_credentials``); absent,
90
+ the port stays insecure.
91
+ logging_config: Logging knobs (access log, level, colors);
92
+ defaults to ``LoggingConfig()``.
93
+ dependencies: App-wide ``Depends`` resolved before every RPC,
94
+ applied automatically to all handlers. Generator dependencies
95
+ wrap each call (pre/`yield`/post).
96
+ **server_options: Forwarded verbatim to ``grpc.server()`` at
97
+ startup (``options``, ``compression``, ``interceptors``,
98
+ ``maximum_concurrent_rpcs``, ``xds``, ...). Passing
99
+ ``thread_pool`` here raises ``TypeError``. ``handlers``
100
+ bypasses this library's servicer registration and is only
101
+ useful for exotic ``GenericRpcHandler`` setups.
102
+ """
103
+ self.thread_pool = thread_pool or ThreadPoolExecutor()
104
+ self.proto_config = ProtoConfig.config_proto_files(
105
+ raw_protos=raw_protos,
106
+ proto_path=proto_path,
107
+ proto_files=proto_files,
108
+ service_path=service_path,
109
+ )
110
+ self.port = port
111
+ self.credentials = credentials
112
+ self.logging_config = logging_config or LoggingConfig()
113
+ self._handlers: dict[str, dict[str, Callable]] = {}
114
+ self._schemaless_handlers: dict[str, dict[str, Callable]] = {}
115
+ self._app_dependencies: tuple[Depends, ...] = tuple(dependencies or ())
116
+ self._server_options: ServerOptions = server_options
117
+
118
+ def _start(self) -> None:
119
+ """Compile, load generated modules, register services, and serve."""
120
+
121
+ configure_logging(self.logging_config)
122
+
123
+ logger.info("Starting simple_grpc (pid %s)", os.getpid())
124
+ mods: dict[str, tuple[ModuleType, ModuleType]] = {}
125
+
126
+ if self._handlers:
127
+ self._compile()
128
+ proto = self.proto_config
129
+ logger.info(
130
+ "Compiled %d proto file(s) from '%s' into '%s'",
131
+ len(proto.proto_files),
132
+ proto.proto_path,
133
+ proto.service_path,
134
+ )
135
+ mods = self._load_generated()
136
+
137
+ server = grpc.server(self.thread_pool, **self._server_options)
138
+ self._register_services(mods, server)
139
+ self._register_schemaless(server)
140
+ registered = {**self._handlers, **self._schemaless_handlers}
141
+ services = ", ".join(f"{svc} ({', '.join(m)})" for svc, m in registered.items())
142
+ logger.info("Registered services: %s", services or "(none)")
143
+
144
+ if self.credentials:
145
+ server.add_secure_port(f"[::]:{self.port}", self.credentials.to_server_credentials())
146
+ tls = "(TLS)"
147
+ else:
148
+ server.add_insecure_port(f"[::]:{self.port}")
149
+ tls = f"({style('No', self.logging_config.use_colors, bold=True)} TLS)"
150
+
151
+ server.start()
152
+ logger.info("Serving on [::]:%d %s - press CTRL+C to quit", self.port, tls)
153
+ server.wait_for_termination()
154
+
155
+ def _compile(self) -> None:
156
+ """
157
+ Run protoc against the configured proto files.
158
+
159
+ Raises:
160
+ RuntimeError: If ``protoc`` exits with a non-zero status.
161
+ """
162
+ if not self.proto_config.service_path.exists(): # type: ignore[reportOptionalMemberAccess]
163
+ raise ValueError(f"service_path '{self.proto_config.service_path}' does not exist.")
164
+ if len(self.proto_config.proto_files) == 0:
165
+ raise RuntimeError(
166
+ f"no proto files in directory: {self.proto_config.proto_path} - "
167
+ f"proto-backed @app.rpc handlers require a .proto file; or use "
168
+ f"@app.schemaless_rpc for endpoints without a schema"
169
+ )
170
+ if protoc.main(self.proto_config.to_compiler_args()) != 0:
171
+ raise RuntimeError(f"protoc failed for {self.proto_config.proto_files}")
172
+
173
+ def _load_generated(self) -> dict[str, tuple[ModuleType, ModuleType]]:
174
+ """
175
+ Import the freshly compiled ``*_pb2``/``*_pb2_grpc`` modules.
176
+
177
+ Returns:
178
+ Mapping of proto stem to ``(pb2_module, pb2_grpc_module)``.
179
+ """
180
+ sp = self.proto_config.service_path
181
+ mods: dict[str, tuple[ModuleType, ModuleType]] = {}
182
+
183
+ for pf in self.proto_config.proto_files:
184
+ name = Path(pf).stem
185
+ pb2 = utils.module_from_file(sp / f"{name}_pb2.py", f"{name}_pb2")
186
+ # _pb2_grpc does a bare `import <name>_pb2`, so seed sys.modules;
187
+ # pop first so a recompile in the dev loop never serves stale code.
188
+ sys.modules.pop(f"{name}_pb2", None)
189
+ sys.modules[f"{name}_pb2"] = pb2
190
+ pb2_grpc = utils.module_from_file(sp / f"{name}_pb2_grpc.py", f"{name}_pb2_grpc") # type: ignore
191
+ mods[name] = (pb2, pb2_grpc)
192
+
193
+ return mods
194
+
195
+ def _build_servicer(self, service: str, pb2: ModuleType, pb2_grpc: ModuleType) -> Any:
196
+ """
197
+ Subclass the generated servicer so each RPC dispatches to its handler.
198
+
199
+ Args:
200
+ service: Service name.
201
+ pb2: Generated messages module.
202
+ pb2_grpc: Generated service classes module.
203
+
204
+ Returns:
205
+ An instance of the generated servicer subclass.
206
+
207
+ Raises:
208
+ ValueError: If a registered handler names an unknown RPC method.
209
+ """
210
+ base = getattr(pb2_grpc, f"{service}Servicer")
211
+ handlers = self._handlers[service]
212
+ unknown = set(handlers) - set(vars(base))
213
+ if unknown:
214
+ available = list(pb2.DESCRIPTOR.services_by_name[service].methods_by_name)
215
+ raise ValueError(
216
+ f"service {service!r} has no RPC methods: {sorted(unknown)}. Available: {available}"
217
+ )
218
+
219
+ methods = pb2.DESCRIPTOR.services_by_name[service].methods_by_name
220
+ package = pb2.DESCRIPTOR.package
221
+ prefix = f"{package}.{service}" if package else service
222
+ overrides = {
223
+ name: self._wrap_handler(
224
+ handler,
225
+ message_factory.GetMessageClass(methods[name].output_type),
226
+ self._app_dependencies,
227
+ path=f"/{prefix}/{name}",
228
+ )
229
+ for name, handler in handlers.items()
230
+ }
231
+ return type("SimpleGRPCServicer", (base,), overrides)()
232
+
233
+ def _register_services(
234
+ self, mods: dict[str, tuple[ModuleType, ModuleType]], server: grpc.Server
235
+ ) -> None:
236
+ """
237
+ Attach a dispatching servicer to the server for each service.
238
+
239
+ Args:
240
+ mods: Mapping returned by :meth:`_load_generated`.
241
+ server: The server receiving the servicers.
242
+ """
243
+ for service in self._handlers:
244
+ pb2, pb2_grpc = utils.find_service(mods, service)
245
+ servicer = self._build_servicer(service, pb2, pb2_grpc)
246
+ getattr(pb2_grpc, f"add_{service}Servicer_to_server")(servicer, server)
247
+
248
+ def _register_schemaless(self, server: grpc.Server) -> None:
249
+ """
250
+ Attach one generic RPC handler per schemaless service.
251
+
252
+ Args:
253
+ server: The server receiving the generic RPC handlers.
254
+ """
255
+ for service, methods in self._schemaless_handlers.items():
256
+ method_handlers = {
257
+ name: dispatch.schemaless_method_handler(
258
+ handler,
259
+ dependencies=self._app_dependencies,
260
+ path=f"/{service}/{name}",
261
+ )
262
+ for name, handler in methods.items()
263
+ }
264
+ server.add_generic_rpc_handlers(
265
+ (grpc.method_handlers_generic_handler(service, method_handlers),)
266
+ )
267
+
268
+ @classmethod
269
+ def _stream(
270
+ cls,
271
+ result,
272
+ response_cls: type[Message],
273
+ context: grpc.ServicerContext,
274
+ tails,
275
+ path: str = "",
276
+ ) -> Iterator[Any]:
277
+ """Delegate to :func:`simple_grpc.dispatch.stream`."""
278
+ return dispatch.stream(result, response_cls, context, tails, path=path)
279
+
280
+ @classmethod
281
+ def _wrap_handler(
282
+ cls,
283
+ handler: Callable,
284
+ response_cls: type[Message],
285
+ dependencies: tuple[Depends, ...] = (),
286
+ path: str = "",
287
+ ) -> Callable:
288
+ """Delegate to :func:`simple_grpc.dispatch.wrap_handler`."""
289
+ return dispatch.wrap_handler(handler, response_cls, dependencies=dependencies, path=path)
290
+
291
+ @property
292
+ def protos(self) -> dict[str, str]:
293
+ """
294
+ Raw content of every configured ``.proto`` file, keyed by file name.
295
+
296
+ Read on access, so edits to the proto files show up under hot reload.
297
+ """
298
+ return {
299
+ pf: (self.proto_config.proto_path / pf).read_text(encoding="utf-8")
300
+ for pf in self.proto_config.proto_files
301
+ }
302
+
303
+ def rpc(
304
+ self,
305
+ service: str,
306
+ method: str,
307
+ ) -> Callable[[Callable], Callable]:
308
+ """Decorate a function as the handler for one RPC method.
309
+
310
+ Args:
311
+ service: Proto service name, e.g. ``"RouteGuide"``.
312
+ method: RPC method name within the service, e.g. ``"GetFeature"``.
313
+ Dependencies may be declared via ``Depends(...)`` parameter
314
+ defaults; app-wide and signature dependencies run in that
315
+ order, outermost first.
316
+
317
+ Returns:
318
+ A decorator that registers the function and returns it unchanged.
319
+ """
320
+
321
+ def decorator(func: Callable) -> Callable:
322
+ """Register ``func`` under ``(service, method)``.
323
+
324
+ Args:
325
+ func: The decorated handler function.
326
+
327
+ Returns:
328
+ The same function, unchanged.
329
+ """
330
+ self._handlers.setdefault(service, {})[method] = func
331
+ return func
332
+
333
+ return decorator
334
+
335
+ def schemaless_rpc(
336
+ self,
337
+ service: str,
338
+ method: str,
339
+ ) -> Callable[[Callable], Callable]:
340
+ """
341
+ Decorate a function as the handler for one RPC, with no proto schema.
342
+
343
+ The endpoint is served without any ``.proto`` definition, using
344
+ ``google.protobuf.Struct`` as the wire format on both sides: the
345
+ handler receives the request as a plain dict and returns a plain
346
+ dict (or a ``Struct`` message, or ``None``), exactly like the dict
347
+ contract of :meth:`rpc`.
348
+
349
+ The RPC flavor is inferred from the handler shape: declaring
350
+ ``request_iterator`` makes it request-streaming; being a generator
351
+ function (or annotating an ``Iterator``/``Generator``/``Iterable``
352
+ return) makes it response-streaming; anything else is unary.
353
+ Dependencies may be declared via ``Depends(...)`` parameter
354
+ defaults, like :meth:`rpc`.
355
+
356
+ Args:
357
+ service: Service name as clients address it, e.g.
358
+ ``"Greeter"`` or a dotted ``"pkg.Greeter"``. The full method
359
+ path is ``/{service}/{method}``.
360
+ method: RPC method name within the service.
361
+
362
+ Returns:
363
+ A decorator that registers the function and returns it unchanged.
364
+ """
365
+
366
+ def decorator(func: Callable) -> Callable:
367
+ """Register ``func`` under ``(service, method)``.
368
+
369
+ Args:
370
+ func: The decorated handler function.
371
+
372
+ Returns:
373
+ The same function, unchanged.
374
+ """
375
+ self._schemaless_handlers.setdefault(service, {})[method] = func
376
+ return func
377
+
378
+ return decorator
379
+
380
+ def dev(
381
+ self,
382
+ reload: bool = True,
383
+ reload_dirs: list[str | Path] | None = None,
384
+ reload_excludes: list[str | Path] | None = None,
385
+ ) -> None:
386
+ """
387
+ Serve with hot reload: editing watched files restarts the server.
388
+
389
+ The app runs in a spawned child process, rebuilt from its module on
390
+ every restart (fresh imports, fresh ``protoc`` compile). By default
391
+ the working directory is watched, excluding the proto and service
392
+ directories plus the generated ``*_pb2*.py`` (protoc rewrites them
393
+ at every child start, which would otherwise loop reloads forever).
394
+
395
+ Args:
396
+ reload: Serve in-process like :meth:`run` when False.
397
+ reload_dirs: Directories to watch; defaults to the working
398
+ directory.
399
+ reload_excludes: Extra glob patterns and/or directories to
400
+ ignore, on top of the defaults (proto/service directories,
401
+ ``*_pb2.py``/``*_pb2_grpc.py``).
402
+
403
+ Raises:
404
+ ImportError: If ``watchfiles`` is not installed.
405
+ RuntimeError: If the app is not bound to a module-level
406
+ variable, or if ``protoc`` exits with a non-zero status.
407
+ """
408
+ configure_logging(self.logging_config)
409
+
410
+ if not reload:
411
+ self._start()
412
+ return
413
+
414
+ try:
415
+ from simple_grpc import reloading
416
+ except ImportError as exc:
417
+ raise ImportError(
418
+ "hot reload requires watchfiles: install it with 'pip install watchfiles'"
419
+ ) from exc
420
+
421
+ module_ref, attr = reloading.locate_app(self, sys._getframe(1))
422
+ reloading.serve_with_reload(
423
+ self,
424
+ module_ref,
425
+ attr,
426
+ reload_dirs=reload_dirs,
427
+ reload_excludes=reload_excludes,
428
+ )
429
+
430
+ def run(self) -> None:
431
+ """Serve like :meth:`dev`; compile-skipping is deferred until needed.
432
+
433
+ Raises:
434
+ RuntimeError: If ``protoc`` exits with a non-zero status.
435
+ """
436
+ self._start()