twirpy 0.1.0.dev3__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,2 @@
1
+ __pycache__/
2
+ dist/
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright 2024, [pafin Inc.](https://www.pafin.com)
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.3
2
+ Name: twirpy
3
+ Version: 0.1.0.dev3
4
+ Summary: Twirp runtime library for Python
5
+ Project-URL: repository, https://github.com/cryptact/twirpy
6
+ License-Expression: BSD-3-Clause
7
+ License-File: LICENSE
8
+ Keywords: protobuf,rpc,twirp
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: protobuf
11
+ Requires-Dist: requests
12
+ Requires-Dist: structlog
13
+ Provides-Extra: async
14
+ Requires-Dist: aiohttp; extra == 'async'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Twirpy
18
+
19
+ > Twirp is a framework for service-to-service communication emphasizing simplicity and minimalism.
20
+ > It generates routing and serialization from API definition files and lets you focus on your application's logic
21
+ > instead of thinking about folderol like HTTP methods and paths and JSON.
22
+ >
23
+ > -- <cite>[Twirp's README](https://github.com/twitchtv/twirp/blob/main/README.md)</cite>
24
+
25
+ Twirpy is a Python implementation of the Twirp framework.
26
+ It currently supports [Twirp Wire Protocol v7](https://twitchtv.github.io/twirp/docs/spec_v7.html).
27
+
28
+ This repository contains:
29
+ * a protoc (aka the Protocol Compiler) plugin that generates sever and client code;
30
+ * a Python package with common implementation details.
31
+
32
+ ## Installation
33
+
34
+ ### Runtime Library
35
+
36
+ The runtime library package contains common types like `TwirpServer` and `TwirpClient` that are used by the generated code.
37
+
38
+ Add the Twirp package to your Python project with:
39
+ ```
40
+ pip install twirpy
41
+ ```
42
+
43
+ ### Code Generator
44
+
45
+ You need to install `protoc`, the Protocol Buffers compiler, and the `protoc-gen-twirpy` protoc plugin to generate code.
46
+
47
+ First, install the [Protocol Buffers](https://developers.google.com/protocol-buffers) compiler.
48
+ For installation instructions, see [Protocol Buffer Compiler Installation documentation](https://github.com/protocolbuffers/protobuf#protobuf-compiler-installation).
49
+ You can also use your package manager (e.g. `brew install protobuf` on macOS).
50
+
51
+ Go the [releases page](https://github.com/Cryptact/twirpy/releases/latest), and download the `protoc-gen-twirpy` binary for your platform.
52
+ Unzip the archive and move the binary to a directory in your PATH.
53
+
54
+ On macOS, you can use the following commands:
55
+ ```sh
56
+ curl -L -o- \
57
+ https://github.com/Cryptact/twirpy/releases/latest/download/protoc-gen-twirpy_Darwin_arm64.tar.gz \
58
+ | tar xz -C ~/.local/bin protoc-gen-twirpy
59
+ ````
60
+
61
+ ## Generate and run
62
+
63
+ Use the protoc plugin to generate twirp server and client code.
64
+ ```sh
65
+ protoc --python_out=. --pyi_out=. --twirpy_out=. example/rpc/haberdasher/service.proto
66
+ ```
67
+
68
+ For more information on how to generate code, see the [example](example/README.md).
69
+
70
+ ## Development
71
+
72
+ We use [`hatch`](https://hatch.pypa.io/latest/) to manage the development process.
73
+
74
+ To open a shell with the development environment, run: `hatch shell`.
75
+ To run the linter, run: `hatch fmt --check` or `hatch fmt` to fix the issues.
76
+
77
+ ## Standing on the shoulders of giants
78
+
79
+ - The initial version of twirpy was made from an internal copy of https://github.com/daroot/protoc-gen-twirp_python_srv
80
+ - The work done by [Verloop](https://verloop.io/) on [the initial versions of Twirpy](https://github.com/verloop/twirpy).
81
+ - The `run_in_threadpool` method comes from https://github.com/encode/starlette
@@ -0,0 +1,65 @@
1
+ # Twirpy
2
+
3
+ > Twirp is a framework for service-to-service communication emphasizing simplicity and minimalism.
4
+ > It generates routing and serialization from API definition files and lets you focus on your application's logic
5
+ > instead of thinking about folderol like HTTP methods and paths and JSON.
6
+ >
7
+ > -- <cite>[Twirp's README](https://github.com/twitchtv/twirp/blob/main/README.md)</cite>
8
+
9
+ Twirpy is a Python implementation of the Twirp framework.
10
+ It currently supports [Twirp Wire Protocol v7](https://twitchtv.github.io/twirp/docs/spec_v7.html).
11
+
12
+ This repository contains:
13
+ * a protoc (aka the Protocol Compiler) plugin that generates sever and client code;
14
+ * a Python package with common implementation details.
15
+
16
+ ## Installation
17
+
18
+ ### Runtime Library
19
+
20
+ The runtime library package contains common types like `TwirpServer` and `TwirpClient` that are used by the generated code.
21
+
22
+ Add the Twirp package to your Python project with:
23
+ ```
24
+ pip install twirpy
25
+ ```
26
+
27
+ ### Code Generator
28
+
29
+ You need to install `protoc`, the Protocol Buffers compiler, and the `protoc-gen-twirpy` protoc plugin to generate code.
30
+
31
+ First, install the [Protocol Buffers](https://developers.google.com/protocol-buffers) compiler.
32
+ For installation instructions, see [Protocol Buffer Compiler Installation documentation](https://github.com/protocolbuffers/protobuf#protobuf-compiler-installation).
33
+ You can also use your package manager (e.g. `brew install protobuf` on macOS).
34
+
35
+ Go the [releases page](https://github.com/Cryptact/twirpy/releases/latest), and download the `protoc-gen-twirpy` binary for your platform.
36
+ Unzip the archive and move the binary to a directory in your PATH.
37
+
38
+ On macOS, you can use the following commands:
39
+ ```sh
40
+ curl -L -o- \
41
+ https://github.com/Cryptact/twirpy/releases/latest/download/protoc-gen-twirpy_Darwin_arm64.tar.gz \
42
+ | tar xz -C ~/.local/bin protoc-gen-twirpy
43
+ ````
44
+
45
+ ## Generate and run
46
+
47
+ Use the protoc plugin to generate twirp server and client code.
48
+ ```sh
49
+ protoc --python_out=. --pyi_out=. --twirpy_out=. example/rpc/haberdasher/service.proto
50
+ ```
51
+
52
+ For more information on how to generate code, see the [example](example/README.md).
53
+
54
+ ## Development
55
+
56
+ We use [`hatch`](https://hatch.pypa.io/latest/) to manage the development process.
57
+
58
+ To open a shell with the development environment, run: `hatch shell`.
59
+ To run the linter, run: `hatch fmt --check` or `hatch fmt` to fix the issues.
60
+
61
+ ## Standing on the shoulders of giants
62
+
63
+ - The initial version of twirpy was made from an internal copy of https://github.com/daroot/protoc-gen-twirp_python_srv
64
+ - The work done by [Verloop](https://verloop.io/) on [the initial versions of Twirpy](https://github.com/verloop/twirpy).
65
+ - The `run_in_threadpool` method comes from https://github.com/encode/starlette
@@ -0,0 +1,62 @@
1
+ [build-system]
2
+ requires = [
3
+ "hatchling",
4
+ "hatch-semver"
5
+ ]
6
+ build-backend = "hatchling.build"
7
+
8
+ [project]
9
+ name = "twirpy"
10
+ dynamic = ["version"]
11
+ description = "Twirp runtime library for Python"
12
+ readme = "README.md"
13
+ requires-python = ">=3.10"
14
+ license = "BSD-3-Clause"
15
+ keywords = [
16
+ "protobuf",
17
+ "rpc",
18
+ "twirp",
19
+ ]
20
+ dependencies = [
21
+ "protobuf",
22
+ "requests",
23
+ "structlog",
24
+ ]
25
+
26
+ [project.urls]
27
+ repository = "https://github.com/cryptact/twirpy"
28
+
29
+ [project.optional-dependencies]
30
+ async = [
31
+ "aiohttp",
32
+ ]
33
+
34
+ [tool.hatch.version]
35
+ path = "twirp/__init__.py"
36
+ validate-bump = true
37
+ scheme = "semver"
38
+
39
+ [tool.hatch.build.targets.sdist]
40
+ include = ["/twirp"]
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["twirp"]
43
+
44
+ [tool.hatch.envs.default]
45
+ python = "3.12"
46
+ dependencies = [
47
+ "aiohttp",
48
+ ]
49
+
50
+ [tool.ruff]
51
+ line-length = 120
52
+ lint.select = [
53
+ "F", # pyflakes
54
+ "PLE", # pylint errors
55
+ "UP", # pyupgrade
56
+ ]
57
+ lint.fixable = [
58
+ "F", # pyflakes
59
+ "PLE", # pylint errors
60
+ "UP", # pyupgrade
61
+ ]
62
+ exclude = ["example/rpc"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0-dev.3"
@@ -0,0 +1,178 @@
1
+ import asyncio
2
+ import functools
3
+ import typing
4
+ import traceback
5
+
6
+ from . import base
7
+ from . import exceptions
8
+ from . import errors
9
+ from . import ctxkeys
10
+
11
+ try:
12
+ import contextvars # Python 3.7+ only.
13
+ except ImportError: # pragma: no cover
14
+ contextvars = None # type: ignore
15
+
16
+
17
+ # Lifted from starlette.concurrency
18
+ async def run_in_threadpool(func: typing.Callable, *args: typing.Any, **kwargs: typing.Any) -> typing.Any:
19
+ loop = asyncio.get_event_loop()
20
+ if contextvars is not None: # pragma: no cover
21
+ # Ensure we run in the same context
22
+ child = functools.partial(func, *args, **kwargs)
23
+ context = contextvars.copy_context()
24
+ func = context.run
25
+ args = (child,)
26
+ elif kwargs: # pragma: no cover
27
+ # loop.run_in_executor doesn't accept 'kwargs', so bind them in here
28
+ func = functools.partial(func, **kwargs)
29
+ return await loop.run_in_executor(None, func, *args)
30
+
31
+
32
+ def thread_pool_runner(func):
33
+ async def run(ctx, request):
34
+ return await run_in_threadpool(func, ctx, request)
35
+
36
+ return run
37
+
38
+
39
+ class TwirpASGIApp(base.TwirpBaseApp):
40
+ async def __call__(self, scope, receive, send):
41
+ assert scope["type"] == "http"
42
+ ctx = self._ctx_class()
43
+ try:
44
+ http_method = scope["method"]
45
+ if http_method != "POST":
46
+ raise exceptions.TwirpServerException(
47
+ code=errors.Errors.BadRoute,
48
+ message="unsupported method " + http_method + " (only POST is allowed)",
49
+ meta={"twirp_invalid_route": http_method + " " + scope["path"]},
50
+ )
51
+
52
+ headers = {k.decode("utf-8"): v.decode("utf-8") for (k, v) in scope["headers"]}
53
+ ctx.set(ctxkeys.RAW_REQUEST_PATH, scope["path"])
54
+ ctx.set(ctxkeys.RAW_HEADERS, headers)
55
+ self._hook.request_received(ctx=ctx)
56
+
57
+ endpoint = self._get_endpoint(scope["path"])
58
+ headers = {k.decode("utf-8"): v.decode("utf-8") for (k, v) in scope["headers"]}
59
+ self.validate_content_length(headers=headers)
60
+ encoder, decoder = self._get_encoder_decoder(endpoint, headers)
61
+
62
+ # add headers from request into context
63
+ ctx.set(ctxkeys.SERVICE_NAME, endpoint.service_name)
64
+ ctx.set(ctxkeys.METHOD_NAME, endpoint.name)
65
+ ctx.set(ctxkeys.RESPONSE_STATUS, 200)
66
+ self._hook.request_routed(ctx=ctx)
67
+ raw_receive = await self._recv_all(receive)
68
+ request = decoder(raw_receive)
69
+ response_data = await self._with_middlewares(func=endpoint.function, ctx=ctx, request=request)
70
+ self._hook.response_prepared(ctx=ctx)
71
+
72
+ body_bytes, headers = encoder(response_data)
73
+ headers = dict(ctx.get_response_headers(), **headers)
74
+ # Todo: middleware
75
+ await self._respond(send=send, status=200, headers=headers, body_bytes=body_bytes)
76
+ self._hook.response_sent(ctx=ctx)
77
+ except Exception as e:
78
+ await self.handle_error(ctx, e, scope, receive, send)
79
+
80
+ def _with_middlewares(self, *args, func, ctx, request):
81
+ chain = iter(self._middlewares + (func,))
82
+
83
+ def bind(fn):
84
+ if not asyncio.iscoroutinefunction(fn):
85
+ fn = thread_pool_runner(fn)
86
+
87
+ async def nxt(ctx, request):
88
+ try:
89
+ cur = next(chain)
90
+ return await fn(ctx, request, bind(cur))
91
+ except StopIteration:
92
+ pass
93
+ return await fn(ctx, request)
94
+
95
+ return nxt
96
+
97
+ return bind(next(chain))(ctx, request)
98
+
99
+ async def handle_error(self, ctx, exc, scope, receive, send):
100
+ status = 500
101
+ body_bytes = b"{}"
102
+ logger = ctx.get_logger()
103
+ error_data = {}
104
+ ctx.set(ctxkeys.ORIGINAL_EXCEPTION, exc)
105
+ try:
106
+ if not isinstance(exc, exceptions.TwirpServerException):
107
+ error_data["raw_error"] = str(exc)
108
+ error_data["raw_trace"] = traceback.format_exc()
109
+ logger.exception("got non-twirp exception while processing request", **error_data)
110
+ exc = exceptions.TwirpServerException(code=errors.Errors.Internal, message="Internal non-Twirp Error")
111
+
112
+ body_bytes = exc.to_json_bytes()
113
+ status = errors.Errors.get_status_code(exc.code)
114
+ except Exception:
115
+ exc = exceptions.TwirpServerException(
116
+ code=errors.Errors.Internal, message="There was an error but it could not be serialized into JSON"
117
+ )
118
+ error_data["raw_error"] = str(exc)
119
+ error_data["raw_trace"] = traceback.format_exc()
120
+ logger.exception("got exception while processing request", **error_data)
121
+ body_bytes = exc.to_json_bytes()
122
+
123
+ ctx.set_logger(logger.bind(**error_data))
124
+ ctx.set(ctxkeys.RESPONSE_STATUS, status)
125
+ self._hook.error(ctx=ctx, exc=exc)
126
+ await self._respond(
127
+ send=send, status=status, headers={"Content-Type": "application/json"}, body_bytes=body_bytes
128
+ )
129
+ self._hook.response_sent(ctx=ctx)
130
+
131
+ async def _respond(self, *args, send, status, headers, body_bytes):
132
+ headers["Content-Length"] = str(len(body_bytes))
133
+ resp_headers = [(k.encode("utf-8"), v.encode("utf-8")) for (k, v) in headers.items()]
134
+ await send(
135
+ {
136
+ "type": "http.response.start",
137
+ "status": status,
138
+ "headers": resp_headers,
139
+ }
140
+ )
141
+ await send(
142
+ {
143
+ "type": "http.response.body",
144
+ "body": body_bytes,
145
+ }
146
+ )
147
+
148
+ async def _recv_all(self, receive):
149
+ body = b""
150
+ more_body = True
151
+ while more_body:
152
+ message = await receive()
153
+ body += message.get("body", b"")
154
+ more_body = message.get("more_body", False)
155
+
156
+ # the body length exceeded than the size set, raise a valid exception
157
+ # so that proper error is returned to the client
158
+ if self._max_receive_message_length < len(body):
159
+ raise exceptions.TwirpServerException(
160
+ code=errors.Errors.InvalidArgument,
161
+ message=f"message body exceeds the specified length of {self._max_receive_message_length} bytes",
162
+ )
163
+
164
+ return body
165
+
166
+ # we will check content-length header value and make sure it is
167
+ # below the limit set
168
+ def validate_content_length(self, headers):
169
+ try:
170
+ content_length = int(headers.get("content-length"))
171
+ except (ValueError, TypeError):
172
+ return
173
+
174
+ if self._max_receive_message_length < content_length:
175
+ raise exceptions.TwirpServerException(
176
+ code=errors.Errors.InvalidArgument,
177
+ message=f"message body exceeds the specified length of {self._max_receive_message_length} bytes",
178
+ )
@@ -0,0 +1,50 @@
1
+ import asyncio
2
+ import json
3
+
4
+ import aiohttp
5
+
6
+ from . import exceptions
7
+ from . import errors
8
+
9
+
10
+ class AsyncTwirpClient:
11
+ def __init__(self, address: str, session: aiohttp.ClientSession | None = None) -> None:
12
+ self._address = address
13
+ self._session = session
14
+
15
+ async def _make_request(self, *, url, ctx, request, response_obj, session=None, **kwargs):
16
+ headers = ctx.get_headers()
17
+ if "headers" in kwargs:
18
+ headers.update(kwargs["headers"])
19
+ kwargs["headers"] = headers
20
+ kwargs["headers"]["Content-Type"] = "application/protobuf"
21
+
22
+ if session is None:
23
+ session = self._session
24
+ if not isinstance(session, aiohttp.ClientSession):
25
+ raise TypeError(f"invalid session type '{type(session).__name__}'")
26
+
27
+ try:
28
+ async with await session.post(url=url, data=request.SerializeToString(), **kwargs) as resp:
29
+ if resp.status == 200:
30
+ response = response_obj()
31
+ response.ParseFromString(await resp.read())
32
+ return response
33
+ try:
34
+ raise exceptions.TwirpServerException.from_json(await resp.json())
35
+ except (aiohttp.ContentTypeError, json.JSONDecodeError):
36
+ raise exceptions.twirp_error_from_intermediary(
37
+ resp.status, resp.reason, resp.headers, await resp.text()
38
+ ) from None
39
+ except asyncio.TimeoutError as e:
40
+ raise exceptions.TwirpServerException(
41
+ code=errors.Errors.DeadlineExceeded,
42
+ message=str(e) or "request timeout",
43
+ meta={"original_exception": e},
44
+ )
45
+ except aiohttp.ServerConnectionError as e:
46
+ raise exceptions.TwirpServerException(
47
+ code=errors.Errors.Unavailable,
48
+ message=str(e),
49
+ meta={"original_exception": e},
50
+ )
@@ -0,0 +1,108 @@
1
+ import functools
2
+ from collections import namedtuple
3
+
4
+ from google.protobuf import json_format
5
+ from google.protobuf import message
6
+ from google.protobuf import symbol_database as _symbol_database
7
+
8
+
9
+ from . import context
10
+
11
+ from . import server
12
+ from . import exceptions
13
+ from . import errors
14
+ from . import hook as vtwirp_hook
15
+
16
+ _sym_lookup = _symbol_database.Default().GetSymbol
17
+
18
+ Endpoint = namedtuple("Endpoint", ["service_name", "name", "function", "input", "output"])
19
+
20
+
21
+ class TwirpBaseApp:
22
+ def __init__(self, *middlewares, hook=None, prefix="", max_receive_message_length=1024 * 100 * 100, ctx_class=None):
23
+ self._prefix = prefix
24
+ self._services = {}
25
+ self._max_receive_message_length = max_receive_message_length
26
+ if ctx_class is None:
27
+ ctx_class = context.Context
28
+ assert issubclass(ctx_class, context.Context)
29
+ self._ctx_class = ctx_class
30
+ self._middlewares = middlewares
31
+ if hook is None:
32
+ hook = vtwirp_hook.TwirpHook()
33
+ assert isinstance(hook, vtwirp_hook.TwirpHook)
34
+ self._hook = hook
35
+
36
+ def add_service(self, svc: server.TwirpServer):
37
+ self._services[self._prefix + svc.prefix] = svc
38
+
39
+ def _get_endpoint(self, path):
40
+ svc = self._services.get(path.rsplit("/", 1)[0], None)
41
+ if svc is None:
42
+ raise exceptions.TwirpServerException(code=errors.Errors.NotFound, message="not found")
43
+
44
+ return svc.get_endpoint(path[len(self._prefix) :])
45
+
46
+ @staticmethod
47
+ def json_decoder(body, data_obj=None):
48
+ data = data_obj()
49
+ try:
50
+ json_format.Parse(body, data)
51
+ except json_format.ParseError as exc:
52
+ raise exceptions.TwirpServerException(
53
+ code=errors.Errors.Malformed,
54
+ message="the json request could not be decoded",
55
+ ) from exc
56
+ return data
57
+
58
+ @staticmethod
59
+ def json_encoder(value, data_obj=None):
60
+ if not isinstance(value, data_obj):
61
+ raise exceptions.TwirpServerException(
62
+ code=errors.Errors.Internal,
63
+ message=(
64
+ "bad service response type " + str(type(value)) + ", expecting: " + data_obj.DESCRIPTOR.full_name
65
+ ),
66
+ )
67
+
68
+ return json_format.MessageToJson(value, preserving_proto_field_name=True).encode("utf-8"), {
69
+ "Content-Type": "application/json"
70
+ }
71
+
72
+ @staticmethod
73
+ def proto_decoder(body, data_obj=None):
74
+ data = data_obj()
75
+ try:
76
+ data.ParseFromString(body)
77
+ except message.DecodeError as exc:
78
+ raise exceptions.TwirpServerException(
79
+ code=errors.Errors.Malformed,
80
+ message="the protobuf request could not be decoded",
81
+ ) from exc
82
+ return data
83
+
84
+ @staticmethod
85
+ def proto_encoder(value, data_obj=None):
86
+ if not isinstance(value, data_obj):
87
+ raise exceptions.TwirpServerException(
88
+ code=errors.Errors.Internal,
89
+ message=(
90
+ "bad service response type " + str(type(value)) + ", expecting: " + data_obj.DESCRIPTOR.full_name
91
+ ),
92
+ )
93
+
94
+ return value.SerializeToString(), {"Content-Type": "application/protobuf"}
95
+
96
+ def _get_encoder_decoder(self, endpoint, headers):
97
+ ctype = headers.get("content-type", None)
98
+ if "application/json" == ctype:
99
+ decoder = functools.partial(self.json_decoder, data_obj=endpoint.input)
100
+ encoder = functools.partial(self.json_encoder, data_obj=endpoint.output)
101
+ elif "application/protobuf" == ctype:
102
+ decoder = functools.partial(self.proto_decoder, data_obj=endpoint.input)
103
+ encoder = functools.partial(self.proto_encoder, data_obj=endpoint.output)
104
+ else:
105
+ raise exceptions.TwirpServerException(
106
+ code=errors.Errors.BadRoute, message="unexpected Content-Type: " + str(ctype)
107
+ )
108
+ return encoder, decoder
@@ -0,0 +1,44 @@
1
+ import requests
2
+
3
+ from . import exceptions
4
+ from . import errors
5
+
6
+
7
+ class TwirpClient:
8
+ def __init__(self, address, timeout=5):
9
+ self._address = address
10
+ self._timeout = timeout
11
+
12
+ def _make_request(self, *args, url, ctx, request, response_obj, **kwargs):
13
+ if "timeout" not in kwargs:
14
+ kwargs["timeout"] = self._timeout
15
+ headers = ctx.get_headers()
16
+ if "headers" in kwargs:
17
+ headers.update(kwargs["headers"])
18
+ kwargs["headers"] = headers
19
+ kwargs["headers"]["Content-Type"] = "application/protobuf"
20
+ try:
21
+ resp = requests.post(url=self._address + url, data=request.SerializeToString(), **kwargs)
22
+ if resp.status_code == 200:
23
+ response = response_obj()
24
+ response.ParseFromString(resp.content)
25
+ return response
26
+ try:
27
+ raise exceptions.TwirpServerException.from_json(resp.json())
28
+ except requests.JSONDecodeError:
29
+ raise exceptions.twirp_error_from_intermediary(
30
+ resp.status_code, resp.reason, resp.headers, resp.text
31
+ ) from None
32
+ # Todo: handle error
33
+ except requests.exceptions.Timeout as e:
34
+ raise exceptions.TwirpServerException(
35
+ code=errors.Errors.DeadlineExceeded,
36
+ message=str(e),
37
+ meta={"original_exception": e},
38
+ )
39
+ except requests.exceptions.ConnectionError as e:
40
+ raise exceptions.TwirpServerException(
41
+ code=errors.Errors.Unavailable,
42
+ message=str(e),
43
+ meta={"original_exception": e},
44
+ )
@@ -0,0 +1,78 @@
1
+ from . import logging
2
+
3
+
4
+ class Context:
5
+ """Context object for storing context information of
6
+ request currently being processed.
7
+ """
8
+
9
+ def __init__(self, *args, logger=None, headers=None):
10
+ """Create a new Context object
11
+
12
+ Keyword arguments:
13
+ logger: Logger that will be used for logging.
14
+ headers: Headers for the request.
15
+ """
16
+ self._values = {}
17
+ if logger is None:
18
+ logger = logging.get_logger()
19
+ self._logger = logger
20
+ if headers is None:
21
+ headers = {}
22
+ self._headers = headers
23
+ self._response_headers = {}
24
+
25
+ def set(self, key, value):
26
+ """Set a Context value
27
+
28
+ Arguments:
29
+ key: Key for the context key-value pair.
30
+ value: Value to be stored.
31
+ """
32
+ self._values[key] = value
33
+
34
+ def get(self, key):
35
+ """Get a Context value
36
+
37
+ Arguments:
38
+ key: Key for the context key-value pair.
39
+ """
40
+ return self._values[key]
41
+
42
+ def get_logger(self):
43
+ """Get current logger used by Context."""
44
+ return self._logger
45
+
46
+ def set_logger(self, logger):
47
+ """Set logger for this Context
48
+
49
+ Arguments:
50
+ logger: Logger object to be used.
51
+ """
52
+ self._logger = logger
53
+
54
+ def get_headers(self):
55
+ """Get request headers that are currently stored."""
56
+ return self._headers
57
+
58
+ def set_header(self, key, value):
59
+ """Set a request header
60
+
61
+ Arguments:
62
+ key: Key for the header.
63
+ value: Value for the header.
64
+ """
65
+ self._headers[key] = value
66
+
67
+ def get_response_headers(self):
68
+ """Get response headers that are currently stored."""
69
+ return self._response_headers
70
+
71
+ def set_response_header(self, key, value):
72
+ """Set a response header
73
+
74
+ Arguments:
75
+ key: Key for the header.
76
+ value: Value for the header.
77
+ """
78
+ self._response_headers[key] = value
@@ -0,0 +1,9 @@
1
+ SERVICE_NAME = "service_name"
2
+ METHOD_NAME = "method_name"
3
+
4
+ RAW_HEADERS = "raw_headers"
5
+ RAW_REQUEST_PATH = "raw_request_path"
6
+
7
+ RESPONSE_STATUS = "response_status"
8
+
9
+ ORIGINAL_EXCEPTION = "original_exception"
@@ -0,0 +1,47 @@
1
+ from enum import Enum
2
+
3
+
4
+ class Errors(Enum):
5
+ Canceled = "canceled"
6
+ Unknown = "unknown"
7
+ InvalidArgument = "invalid_argument"
8
+ DeadlineExceeded = "deadline_exceeded"
9
+ NotFound = "not_found"
10
+ BadRoute = "bad_route"
11
+ AlreadyExists = "already_exists"
12
+ PermissionDenied = "permission_denied"
13
+ Unauthenticated = "unauthenticated"
14
+ ResourceExhausted = "resource_exhausted"
15
+ FailedPrecondition = "failed_precondition"
16
+ Aborted = "aborted"
17
+ OutOfRange = "out_of_range"
18
+ Unimplemented = "unimplemented"
19
+ Internal = "internal"
20
+ Unavailable = "unavailable"
21
+ DataLoss = "data_loss"
22
+ Malformed = "malformed"
23
+ NoError = ""
24
+
25
+ @staticmethod
26
+ def get_status_code(code):
27
+ return {
28
+ Errors.Canceled: 408,
29
+ Errors.Unknown: 500,
30
+ Errors.InvalidArgument: 400,
31
+ Errors.Malformed: 400,
32
+ Errors.DeadlineExceeded: 408,
33
+ Errors.NotFound: 404,
34
+ Errors.BadRoute: 404,
35
+ Errors.AlreadyExists: 409,
36
+ Errors.PermissionDenied: 403,
37
+ Errors.Unauthenticated: 401,
38
+ Errors.ResourceExhausted: 429,
39
+ Errors.FailedPrecondition: 412,
40
+ Errors.Aborted: 409,
41
+ Errors.OutOfRange: 400,
42
+ Errors.Unimplemented: 501,
43
+ Errors.Internal: 500,
44
+ Errors.Unavailable: 503,
45
+ Errors.DataLoss: 500,
46
+ Errors.NoError: 200,
47
+ }.get(code, 500)
@@ -0,0 +1,96 @@
1
+ import json
2
+ from http.client import HTTPException
3
+ from typing import Any
4
+
5
+ from . import errors
6
+
7
+
8
+ class TwirpServerException(HTTPException):
9
+ def __init__(self, *args, code, message, meta: dict[str, Any] | None = None):
10
+ try:
11
+ self._code = errors.Errors(code)
12
+ except ValueError:
13
+ self._code = errors.Errors.Unknown
14
+ self._message = message
15
+ self._meta = meta or {}
16
+ super().__init__(message)
17
+
18
+ @property
19
+ def code(self):
20
+ if isinstance(self._code, errors.Errors):
21
+ return self._code
22
+ return errors.Errors.Unknown
23
+
24
+ @property
25
+ def message(self):
26
+ return self._message
27
+
28
+ @property
29
+ def meta(self):
30
+ return self._meta
31
+
32
+ def to_dict(self):
33
+ err = {"code": self._code.value, "msg": self._message, "meta": {}}
34
+ for k, v in self._meta.items():
35
+ err["meta"][k] = str(v)
36
+ return err
37
+
38
+ def to_json_bytes(self):
39
+ return json.dumps(self.to_dict()).encode("utf-8")
40
+
41
+ @staticmethod
42
+ def from_json(err_dict):
43
+ return TwirpServerException(
44
+ code=err_dict.get("code", errors.Errors.Unknown),
45
+ message=err_dict.get("msg", ""),
46
+ meta=err_dict.get("meta", {}),
47
+ )
48
+
49
+
50
+ def InvalidArgument(*args, argument, error):
51
+ return TwirpServerException(
52
+ code=errors.Errors.InvalidArgument, message=f"{argument} {error}", meta={"argument": argument}
53
+ )
54
+
55
+
56
+ def RequiredArgument(*args, argument):
57
+ return InvalidArgument(argument=argument, error="is required")
58
+
59
+
60
+ def twirp_error_from_intermediary(status, reason, headers, body):
61
+ # see https://twitchtv.github.io/twirp/docs/errors.html#http-errors-from-intermediary-proxies
62
+ meta = {
63
+ "http_error_from_intermediary": "true",
64
+ "status_code": str(status),
65
+ }
66
+
67
+ if 300 <= status < 400:
68
+ # twirp uses POST which should not redirect
69
+ code = errors.Errors.Internal
70
+ location = headers.get("location")
71
+ message = 'unexpected HTTP status code %d "%s" received, Location="%s"' % (
72
+ status,
73
+ reason,
74
+ location,
75
+ )
76
+ meta["location"] = location
77
+
78
+ else:
79
+ code = {
80
+ 400: errors.Errors.Internal, # JSON response should have been returned
81
+ 401: errors.Errors.Unauthenticated,
82
+ 403: errors.Errors.PermissionDenied,
83
+ 404: errors.Errors.BadRoute,
84
+ 429: errors.Errors.ResourceExhausted,
85
+ 502: errors.Errors.Unavailable,
86
+ 503: errors.Errors.Unavailable,
87
+ 504: errors.Errors.Unavailable,
88
+ }.get(status, errors.Errors.Unknown)
89
+
90
+ message = 'Error from intermediary with HTTP status code %d "%s"' % (
91
+ status,
92
+ reason,
93
+ )
94
+ meta["body"] = body
95
+
96
+ return TwirpServerException(code=code, message=message, meta=meta)
@@ -0,0 +1,47 @@
1
+ class TwirpHook:
2
+ # Called as soon as a request is received, always called
3
+ def request_received(self, *args, ctx):
4
+ pass
5
+
6
+ # Called once the request is routed, service name known, only called if request is routable
7
+ def request_routed(self, *args, ctx):
8
+ pass
9
+
10
+ # Called once the response is prepared, not called for error cases
11
+ def response_prepared(self, *args, ctx):
12
+ pass
13
+
14
+ # Called if an error occurs
15
+ def error(self, *args, ctx, exc):
16
+ pass
17
+
18
+ # Called after error is sent, always called
19
+ def response_sent(self, *args, ctx):
20
+ pass
21
+
22
+
23
+ class ChainHooks(TwirpHook):
24
+ def __init__(self, *hooks):
25
+ for hook in hooks:
26
+ assert isinstance(hook, TwirpHook)
27
+ self._hooks = hooks
28
+
29
+ def request_received(self, *args, ctx):
30
+ for hook in self._hooks:
31
+ hook.request_received(ctx=ctx)
32
+
33
+ def request_routed(self, *args, ctx):
34
+ for hook in self._hooks:
35
+ hook.request_routed(ctx=ctx)
36
+
37
+ def response_prepared(self, *args, ctx):
38
+ for hook in self._hooks:
39
+ hook.response_prepared(ctx=ctx)
40
+
41
+ def error(self, *args, ctx, exc):
42
+ for hook in self._hooks:
43
+ hook.error(ctx=ctx, exc=exc)
44
+
45
+ def response_sent(self, *args, ctx):
46
+ for hook in self._hooks:
47
+ hook.response_sent(ctx=ctx)
@@ -0,0 +1,62 @@
1
+ import os
2
+ import logging
3
+
4
+ import structlog
5
+ from structlog.stdlib import LoggerFactory, add_log_level
6
+
7
+ _configured = False
8
+
9
+
10
+ def configure(force=False):
11
+ """
12
+ Configures logging & structlog modules
13
+
14
+ Keyword Arguments:
15
+ force: Force to reconfigure logging.
16
+ """
17
+
18
+ global _configured
19
+ if _configured and not force:
20
+ return
21
+
22
+ # Check whether debug flag is set
23
+ debug = os.environ.get("DEBUG_MODE", False)
24
+ # Set appropriate log level
25
+ if debug:
26
+ log_level = logging.DEBUG
27
+ else:
28
+ log_level = logging.INFO
29
+
30
+ # Set logging config
31
+ logging.basicConfig(
32
+ level=log_level,
33
+ format="%(message)s",
34
+ )
35
+
36
+ # Configure structlog
37
+ structlog.configure(
38
+ logger_factory=LoggerFactory(),
39
+ processors=[
40
+ add_log_level,
41
+ # Add timestamp
42
+ structlog.processors.TimeStamper("iso"),
43
+ # Add stack information
44
+ structlog.processors.StackInfoRenderer(),
45
+ # Set exception field using exec info
46
+ structlog.processors.format_exc_info,
47
+ # Render event_dict as JSON
48
+ structlog.processors.JSONRenderer(),
49
+ ],
50
+ )
51
+
52
+ _configured = True
53
+
54
+
55
+ def get_logger(**kwargs):
56
+ """
57
+ Get the structlog logger
58
+ """
59
+ # Configure logging modules
60
+ configure()
61
+ # Return structlog
62
+ return structlog.get_logger(**kwargs)
@@ -0,0 +1,32 @@
1
+ from . import exceptions
2
+ from . import errors
3
+
4
+
5
+ class TwirpServer:
6
+ def __init__(self, *args, service):
7
+ self.service = service
8
+ self._endpoints = {}
9
+ self._prefix = ""
10
+
11
+ @property
12
+ def prefix(self):
13
+ return self._prefix
14
+
15
+ def get_endpoint(self, path):
16
+ (_, url_pre, rpc_method) = path.rpartition(self._prefix + "/")
17
+ if not url_pre or not rpc_method:
18
+ raise exceptions.TwirpServerException(
19
+ code=errors.Errors.BadRoute,
20
+ message="no handler for path " + path,
21
+ meta={"twirp_invalid_route": "POST " + path},
22
+ )
23
+
24
+ endpoint = self._endpoints.get(rpc_method, None)
25
+ if not endpoint:
26
+ raise exceptions.TwirpServerException(
27
+ code=errors.Errors.Unimplemented,
28
+ message="service has no endpoint " + rpc_method,
29
+ meta={"twirp_invalide_route": "POST " + path},
30
+ )
31
+
32
+ return endpoint