Pyjoyment 0.1.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.
Files changed (200) hide show
  1. Pyjo/App/ASGI.py +735 -0
  2. Pyjo/App/CGI.py +773 -0
  3. Pyjo/App/FastCGI.py +631 -0
  4. Pyjo/App/Lambda.py +987 -0
  5. Pyjo/App/WSGI.py +427 -0
  6. Pyjo/App/__init__.py +409 -0
  7. Pyjo/Asset/File.py +377 -0
  8. Pyjo/Asset/Memory.py +235 -0
  9. Pyjo/Asset/__init__.py +200 -0
  10. Pyjo/Base.py +215 -0
  11. Pyjo/Cache.py +119 -0
  12. Pyjo/Collection.py +570 -0
  13. Pyjo/Content/MultiPart.py +325 -0
  14. Pyjo/Content/Single.py +193 -0
  15. Pyjo/Content/__init__.py +806 -0
  16. Pyjo/Cookie/Request.py +90 -0
  17. Pyjo/Cookie/Response.py +294 -0
  18. Pyjo/Cookie/__init__.py +143 -0
  19. Pyjo/DOM/CSS.py +1019 -0
  20. Pyjo/DOM/HTML.py +550 -0
  21. Pyjo/DOM/__init__.py +1329 -0
  22. Pyjo/Date.py +294 -0
  23. Pyjo/EventEmitter.py +522 -0
  24. Pyjo/Exception.py +494 -0
  25. Pyjo/File.py +959 -0
  26. Pyjo/GetOpt.py +605 -0
  27. Pyjo/Headers.py +760 -0
  28. Pyjo/HelloWorld.py +77 -0
  29. Pyjo/Home.py +109 -0
  30. Pyjo/IOLoop/ChildProcess.py +591 -0
  31. Pyjo/IOLoop/Client.py +736 -0
  32. Pyjo/IOLoop/Datagram.py +982 -0
  33. Pyjo/IOLoop/Messages.py +664 -0
  34. Pyjo/IOLoop/Server.py +458 -0
  35. Pyjo/IOLoop/Stdio.py +446 -0
  36. Pyjo/IOLoop/Stream.py +644 -0
  37. Pyjo/IOLoop/Subprocess.py +419 -0
  38. Pyjo/IOLoop/TLS.py +360 -0
  39. Pyjo/IOLoop/__init__.py +1036 -0
  40. Pyjo/IOLoop/certs/README.md +41 -0
  41. Pyjo/IOLoop/certs/ca.crt +20 -0
  42. Pyjo/IOLoop/certs/server.crt +21 -0
  43. Pyjo/IOLoop/certs/server.key +28 -0
  44. Pyjo/JSON/Pointer.py +135 -0
  45. Pyjo/JSON/RPC.py +772 -0
  46. Pyjo/JSON/Schema.py +2506 -0
  47. Pyjo/JSON/__init__.py +573 -0
  48. Pyjo/Loader.py +342 -0
  49. Pyjo/Log.py +723 -0
  50. Pyjo/Message/Request.py +725 -0
  51. Pyjo/Message/Response.py +476 -0
  52. Pyjo/Message/__init__.py +830 -0
  53. Pyjo/Parameters.py +472 -0
  54. Pyjo/Path.py +419 -0
  55. Pyjo/Promise.py +894 -0
  56. Pyjo/Prove.py +1305 -0
  57. Pyjo/Reactor/Asyncio.py +854 -0
  58. Pyjo/Reactor/Poll.py +439 -0
  59. Pyjo/Reactor/__init__.py +525 -0
  60. Pyjo/SSE.py +119 -0
  61. Pyjo/Server/ASGI.py +486 -0
  62. Pyjo/Server/CGI.py +259 -0
  63. Pyjo/Server/Daemon.py +1007 -0
  64. Pyjo/Server/FastCGI.py +517 -0
  65. Pyjo/Server/Hot/__init__.py +687 -0
  66. Pyjo/Server/Hot/__main__.py +5 -0
  67. Pyjo/Server/Lambda.py +641 -0
  68. Pyjo/Server/Prefork.py +917 -0
  69. Pyjo/Server/Prethread.py +394 -0
  70. Pyjo/Server/Reload/Backend/Poll.py +114 -0
  71. Pyjo/Server/Reload/Backend/__init__.py +126 -0
  72. Pyjo/Server/Reload/__init__.py +482 -0
  73. Pyjo/Server/Reload/__main__.py +5 -0
  74. Pyjo/Server/Stdio.py +228 -0
  75. Pyjo/Server/WSGI.py +349 -0
  76. Pyjo/Server/__init__.py +396 -0
  77. Pyjo/String/Bytes.py +532 -0
  78. Pyjo/String/HTML.py +210 -0
  79. Pyjo/String/Mixin.py +333 -0
  80. Pyjo/String/Template.py +470 -0
  81. Pyjo/String/Unicode.py +553 -0
  82. Pyjo/String/__init__.py +0 -0
  83. Pyjo/TOML.py +232 -0
  84. Pyjo/Template.py +1290 -0
  85. Pyjo/Test/Pyjo.py +1454 -0
  86. Pyjo/Test/__init__.py +1233 -0
  87. Pyjo/Test/pytest_plugin.py +305 -0
  88. Pyjo/Transaction/HTTP.py +374 -0
  89. Pyjo/Transaction/WebSocket.py +709 -0
  90. Pyjo/Transaction/__init__.py +429 -0
  91. Pyjo/URL/Template.py +459 -0
  92. Pyjo/URL/__init__.py +651 -0
  93. Pyjo/Upload.py +139 -0
  94. Pyjo/UserAgent/CookieJar.py +391 -0
  95. Pyjo/UserAgent/Proxy.py +138 -0
  96. Pyjo/UserAgent/Server.py +239 -0
  97. Pyjo/UserAgent/Transactor.py +766 -0
  98. Pyjo/UserAgent/__init__.py +1243 -0
  99. Pyjo/Util.py +3567 -0
  100. Pyjo/WebSocket.py +332 -0
  101. Pyjo/YAML.py +1167 -0
  102. Pyjo/__init__.py +441 -0
  103. Pyjo/__main__.py +35 -0
  104. Pyjo/py.typed +0 -0
  105. Pyjoyment/Command/Author/__init__.py +20 -0
  106. Pyjoyment/Command/Author/generate/__init__.py +106 -0
  107. Pyjoyment/Command/Author/generate/app.py +299 -0
  108. Pyjoyment/Command/Author/generate/dockerfile.py +281 -0
  109. Pyjoyment/Command/Author/generate/kubernetes.py +514 -0
  110. Pyjoyment/Command/Author/generate/lambda_zip.py +372 -0
  111. Pyjoyment/Command/Author/generate/lite_app.py +113 -0
  112. Pyjoyment/Command/Author/generate/plugin.py +267 -0
  113. Pyjoyment/Command/Author/generate/pyproject.py +123 -0
  114. Pyjoyment/Command/Author/inflate.py +101 -0
  115. Pyjoyment/Command/__init__.py +332 -0
  116. Pyjoyment/Command/asgi.py +92 -0
  117. Pyjoyment/Command/cgi.py +90 -0
  118. Pyjoyment/Command/daemon.py +160 -0
  119. Pyjoyment/Command/eval.py +128 -0
  120. Pyjoyment/Command/fastcgi.py +99 -0
  121. Pyjoyment/Command/get.py +305 -0
  122. Pyjoyment/Command/lambda.py +117 -0
  123. Pyjoyment/Command/prefork.py +181 -0
  124. Pyjoyment/Command/prethread.py +126 -0
  125. Pyjoyment/Command/routes.py +133 -0
  126. Pyjoyment/Command/stdio.py +92 -0
  127. Pyjoyment/Command/version.py +141 -0
  128. Pyjoyment/Command/wsgi.py +93 -0
  129. Pyjoyment/Commands.py +546 -0
  130. Pyjoyment/Controller.py +1307 -0
  131. Pyjoyment/HelloWorld.py +56 -0
  132. Pyjoyment/Lite.py +494 -0
  133. Pyjoyment/Plugin/AllowedHosts.py +205 -0
  134. Pyjoyment/Plugin/Config.py +248 -0
  135. Pyjoyment/Plugin/DefaultHelpers.py +1202 -0
  136. Pyjoyment/Plugin/EPLRenderer.py +134 -0
  137. Pyjoyment/Plugin/EPRenderer.py +212 -0
  138. Pyjoyment/Plugin/HeaderCondition.py +98 -0
  139. Pyjoyment/Plugin/Health.py +325 -0
  140. Pyjoyment/Plugin/JSONConfig.py +172 -0
  141. Pyjoyment/Plugin/Mount.py +223 -0
  142. Pyjoyment/Plugin/PyRenderer.py +209 -0
  143. Pyjoyment/Plugin/TOMLConfig.py +141 -0
  144. Pyjoyment/Plugin/TagHelpers.py +959 -0
  145. Pyjoyment/Plugin/YAMLConfig.py +178 -0
  146. Pyjoyment/Plugin/__init__.py +80 -0
  147. Pyjoyment/Plugins.py +215 -0
  148. Pyjoyment/Renderer.py +673 -0
  149. Pyjoyment/Routes/Match.py +251 -0
  150. Pyjoyment/Routes/Pattern.py +443 -0
  151. Pyjoyment/Routes/Route.py +827 -0
  152. Pyjoyment/Routes/__init__.py +419 -0
  153. Pyjoyment/Sessions.py +255 -0
  154. Pyjoyment/Static.py +421 -0
  155. Pyjoyment/Types.py +238 -0
  156. Pyjoyment/Validator/Validation.py +395 -0
  157. Pyjoyment/Validator/__init__.py +304 -0
  158. Pyjoyment/__init__.py +1083 -0
  159. Pyjoyment/__main__.py +5 -0
  160. Pyjoyment/py.typed +0 -0
  161. Pyjoyment/resources/public/favicon.ico +0 -0
  162. Pyjoyment/resources/public/favicon.svg +345 -0
  163. Pyjoyment/resources/public/pyjo/bootstrap/LICENSE +21 -0
  164. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.bundle.min.js.map +1 -0
  165. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.css +6 -0
  166. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.js +7 -0
  167. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.min.css.map +1 -0
  168. Pyjoyment/resources/public/pyjo/failunicat.png +0 -0
  169. Pyjoyment/resources/public/pyjo/failunicat.svg +50 -0
  170. Pyjoyment/resources/public/pyjo/highlight.js/LICENSE +29 -0
  171. Pyjoyment/resources/public/pyjo/highlight.js/highlight-mojo-dark.css +56 -0
  172. Pyjoyment/resources/public/pyjo/highlight.js/highlight.min.js +1262 -0
  173. Pyjoyment/resources/public/pyjo/highlight.js/pyjoyment.min.js +1 -0
  174. Pyjoyment/resources/public/pyjo/logo-black-4x.png +0 -0
  175. Pyjoyment/resources/public/pyjo/logo-black-4x.svg +346 -0
  176. Pyjoyment/resources/public/pyjo/logo-white-2x.png +0 -0
  177. Pyjoyment/resources/public/pyjo/logo-white-2x.svg +346 -0
  178. Pyjoyment/resources/public/pyjo/logo-white-4x.png +0 -0
  179. Pyjoyment/resources/public/pyjo/logo-white-4x.svg +346 -0
  180. Pyjoyment/resources/public/pyjo/logo-white.png +0 -0
  181. Pyjoyment/resources/public/pyjo/logo-white.svg +346 -0
  182. Pyjoyment/resources/public/pyjo/logo.png +0 -0
  183. Pyjoyment/resources/public/pyjo/logo.svg +346 -0
  184. Pyjoyment/resources/public/pyjo/notfound.png +0 -0
  185. Pyjoyment/resources/public/pyjo/notfound.svg +3 -0
  186. Pyjoyment/resources/public/pyjo/nounicat.png +0 -0
  187. Pyjoyment/resources/public/pyjo/nounicat.svg +5 -0
  188. Pyjoyment/resources/public/pyjo/pinstripe-dark.png +0 -0
  189. Pyjoyment/resources/public/pyjo/pinstripe-dark.svg +4 -0
  190. Pyjoyment/resources/public/pyjo/pinstripe-light.png +0 -0
  191. Pyjoyment/resources/public/pyjo/pinstripe-light.svg +4 -0
  192. Pyjoyment/resources/public/pyjo/pyjo.css +204 -0
  193. Pyjoyment/resources/templates/pyjo/debug.html.ep +346 -0
  194. Pyjoyment/resources/templates/pyjo/exception.html.ep +32 -0
  195. Pyjoyment/resources/templates/pyjo/not_found.html.ep +45 -0
  196. pyjoyment-0.1.0.dist-info/METADATA +376 -0
  197. pyjoyment-0.1.0.dist-info/RECORD +200 -0
  198. pyjoyment-0.1.0.dist-info/WHEEL +4 -0
  199. pyjoyment-0.1.0.dist-info/entry_points.txt +8 -0
  200. pyjoyment-0.1.0.dist-info/licenses/LICENSE +201 -0
Pyjo/Server/Lambda.py ADDED
@@ -0,0 +1,641 @@
1
+ """
2
+ Pyjo.Server.Lambda - AWS Lambda server
3
+ ======================================
4
+ ::
5
+
6
+ from Pyjo.Server.Lambda import Lambda
7
+
8
+ server = Lambda()
9
+
10
+
11
+ @server.unsubscribe('request').on('request')
12
+ def request(server, tx):
13
+ # Request
14
+ method = tx.req.method
15
+ path = tx.req.url.path
16
+
17
+ # Response
18
+ tx.res.code = 200
19
+ tx.res.headers.content_type = 'text/plain'
20
+ tx.res.body = f'{method} request for {path}!'.encode()
21
+
22
+ # Resume transaction
23
+ tx.resume()
24
+
25
+
26
+ handler = server.to_lambda_app()
27
+
28
+ # Or import the module, as in Google Python Style Guide
29
+
30
+ import Pyjo.Server.Lambda
31
+
32
+ server = Pyjo.Server.Lambda.new()
33
+
34
+
35
+ @server.unsubscribe('request').on('request')
36
+ def request(server, tx):
37
+ # Request
38
+ method = tx.req.method
39
+ path = tx.req.url.path
40
+
41
+ # Response
42
+ tx.res.code = 200
43
+ tx.res.headers.content_type = 'text/plain'
44
+ tx.res.body = f'{method} request for {path}!'.encode()
45
+
46
+ # Resume transaction
47
+ tx.resume()
48
+
49
+
50
+ handler = server.to_lambda_app()
51
+
52
+ :mod:`Pyjo.Server.Lambda` allows :mod:`Pyjoyment` applications to run as
53
+ event functions of `AWS Lambda <https://aws.amazon.com/lambda/>`_, whose
54
+ handler gets the event of an HTTP request and returns its response, with the
55
+ payload formats of API Gateway, of Function URLs and of Application Load
56
+ Balancers:
57
+
58
+ * the format 2.0 of HTTP APIs and Function URLs, whose ``cookies`` are the
59
+ ``Cookie`` header of the request and the ``Set-Cookie`` headers of the
60
+ response, and whose stage, other than ``$default``, is the base path of the
61
+ URL;
62
+ * the format 1.0 of REST APIs, whose stage is the base path of the URL, and
63
+ whose decoded parameters of the query are encoded again;
64
+ * the events of Application Load Balancers, whose parameters of the query are
65
+ as they were sent, with the headers of multiple values when the target
66
+ group has them, and only the last value of a header otherwise.
67
+
68
+ The event and the context of the invocation are ``lambda.event`` and
69
+ ``lambda.context`` of :attr:`~Pyjo.Message.Request.Pyjo_Message_Request.env`
70
+ of the request. A body that the event has in Base64 is decoded, and the body
71
+ of a response is Base64 too, unless its content type is text, JSON, XML,
72
+ JavaScript or YAML that is UTF-8. The handler is synchronous, so the event
73
+ loop of :mod:`Pyjo.IOLoop` runs until the response is complete, which can
74
+ come later and in chunks, whose framing is left out, as the response of an
75
+ event function is the whole body. ::
76
+
77
+ # The handler "myapp.handler" of an event function
78
+ handler = Pyjo.Server.Lambda.new(app=app).to_lambda_app()
79
+
80
+ With the invoke mode ``RESPONSE_STREAM`` of
81
+ :attr:`~Pyjo.Server.Lambda.Pyjo_Server_Lambda.invoke_mode`, which
82
+ follows the one of the Function URL or the response transfer mode of the
83
+ REST API of API Gateway, the handler returns an iterator of the body as it is
84
+ written instead, which the runtime client below streams: the status and the
85
+ headers as JSON, 8 null bytes, and the chunks of the body as they come, with
86
+ the content type ``application/vnd.awslambda.http-integration-response`` of
87
+ the iterator. The events of Application Load Balancers, which have no
88
+ streaming, get the whole response. ::
89
+
90
+ # PYJO_LAMBDA_INVOKE_MODE=RESPONSE_STREAM in the environment of the function
91
+ handler = Pyjo.Server.Lambda.new(app=app).to_lambda_app()
92
+
93
+ Events
94
+ ------
95
+
96
+ :mod:`Pyjo.Server.Lambda` inherits all events from :mod:`Pyjo.Server`.
97
+
98
+ Attributes
99
+ ----------
100
+
101
+ :mod:`Pyjo.Server.Lambda` inherits all attributes from :mod:`Pyjo.Server` and
102
+ implements :attr:`~Pyjo.Server.Lambda.Pyjo_Server_Lambda.invoke_mode`.
103
+
104
+ Runtime client
105
+ --------------
106
+ ::
107
+
108
+ Usage: python -m Pyjo.Server.Lambda [OPTIONS] [HANDLER]
109
+
110
+ python -m Pyjo.Server.Lambda myapp.handler
111
+ AWS_LAMBDA_RUNTIME_API=127.0.0.1:9001 python -m Pyjo.Server.Lambda myapp.handler
112
+
113
+ Options:
114
+ -h, --help Show this message
115
+
116
+ The client of the `Runtime API
117
+ <https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html>`_ of AWS
118
+ Lambda, which runs the handler of an event function, ``module.function`` or
119
+ the one of ``_HANDLER``, as a custom runtime does: it gets the next
120
+ invocation from the API of ``AWS_LAMBDA_RUNTIME_API``, calls the handler with
121
+ its event and a context, and posts the value that the handler returns as
122
+ JSON, or its exception, one invocation after another. An iterator of bytes
123
+ that the handler returns is a streaming response, chunked as it is iterated,
124
+ of the ``content_type`` of the iterator or ``application/octet-stream``, with
125
+ an exception in the middle of it in the trailers of the error, as the Runtime
126
+ API takes it, and an exception before its first chunk as the error of the
127
+ invocation. An exception of the
128
+ import of the handler is an error of the initialization, which ends the
129
+ client. The handler can be any handler of an event function, also one of
130
+ another framework, which runs an event loop of its own, as the client runs
131
+ outside of the event loop of :mod:`Pyjo.IOLoop`, and the directory of the
132
+ handler, ``LAMBDA_TASK_ROOT`` or the current one, is the first one of
133
+ :data:`sys.path`. ::
134
+
135
+ #!/bin/sh
136
+ # The bootstrap of a custom runtime, as provided.al2023
137
+ exec python3 -m Pyjo.Server.Lambda
138
+
139
+ The context has the attributes and the method of the context of the Python
140
+ runtime of Lambda: ``aws_request_id``, ``invoked_function_arn``,
141
+ ``function_name``, ``function_version``, ``memory_limit_in_mb``,
142
+ ``log_group_name``, ``log_stream_name``, ``identity``, ``client_context``
143
+ and ``get_remaining_time_in_millis()``, from the headers of the invocation
144
+ and the variables of the environment that Lambda sets.
145
+
146
+ See also
147
+ --------
148
+
149
+ :mod:`Pyjoyment.Command.lambda`, :mod:`Pyjo.Server.WSGI`, :mod:`Pyjoyment`,
150
+ https://mojolicious.org.
151
+
152
+ Classes
153
+ -------
154
+ """
155
+
156
+ import Pyjo.Exception
157
+ import Pyjo.IOLoop
158
+ import Pyjo.Parameters
159
+ import Pyjo.Server
160
+ import Pyjo.Transaction
161
+
162
+ from Pyjo.Server.WSGI import _IO # pyright: ignore[reportPrivateUsage]
163
+ from Pyjo.Util import die, extract_usage, getopt, url_escape
164
+
165
+ import base64
166
+ import http.client
167
+ import importlib
168
+ import json
169
+ import os
170
+ import re
171
+ import sys
172
+ import time
173
+ import traceback
174
+ import wsgiref.util
175
+
176
+ from collections.abc import Callable, Iterator, Sequence
177
+ from typing import Any, NoReturn, Self, cast
178
+
179
+
180
+ type Event = dict[str, Any]
181
+ type Response = dict[str, Any]
182
+ type Handler = Callable[[Event, Any], Response | Iterator[bytes]]
183
+
184
+ # The content types of a body that is text, which goes as it is, where other bodies go in Base64
185
+ _RE_TEXT = re.compile(r'(?:text/|application/(?:[\w.-]+\+)?(?:json|xml|javascript|yaml)\b)', re.I)
186
+
187
+
188
+ class Pyjo_Server_Lambda(Pyjo.Server.object):
189
+ """
190
+ :mod:`Pyjo.Server.Lambda` inherits all attributes and methods from
191
+ :mod:`Pyjo.Server` and implements the following new ones.
192
+ """
193
+
194
+ def __init__(self, *, invoke_mode: str | None = None, **kwargs: Any) -> None:
195
+ """::
196
+
197
+ server = Pyjo.Server.Lambda.new()
198
+ server = Pyjo.Server.Lambda.new(invoke_mode='RESPONSE_STREAM')
199
+
200
+ Construct a new :mod:`Pyjo.Server.Lambda` object.
201
+ """
202
+ super().__init__(**kwargs)
203
+ self._invoke_mode = invoke_mode
204
+
205
+ @property
206
+ def invoke_mode(self) -> str:
207
+ """::
208
+
209
+ mode = server.invoke_mode
210
+ server.invoke_mode = 'RESPONSE_STREAM'
211
+
212
+ The mode of the invocations, as the ``InvokeMode`` of the Function
213
+ URL, or the response transfer mode of API Gateway, of the function,
214
+ which the function does not learn from its invocations: ``BUFFERED``
215
+ or ``RESPONSE_STREAM``, defaults to the value of the
216
+ ``PYJO_LAMBDA_INVOKE_MODE`` environment variable or ``BUFFERED``.
217
+ """
218
+ if self._invoke_mode is None:
219
+ self._invoke_mode = os.environ.get('PYJO_LAMBDA_INVOKE_MODE') or 'BUFFERED'
220
+ return self._invoke_mode
221
+
222
+ @invoke_mode.setter
223
+ def invoke_mode(self, value: str) -> None:
224
+ self._invoke_mode = value
225
+
226
+ # reportIncompatibleMethodOverride "Method "run" overrides class "Pyjo_Server" in an incompatible manner": run takes
227
+ # the event and the context, as the run of Pyjo.Server.WSGI takes the environment
228
+ def run( # pyright: ignore[reportIncompatibleMethodOverride]
229
+ self, event: Event, context: Any = None
230
+ ) -> Response | Iterator[bytes]:
231
+ """::
232
+
233
+ response = server.run(event, context)
234
+
235
+ Run the event of an HTTP request and return its response, or the
236
+ iterator of a streaming response with the invoke mode
237
+ ``RESPONSE_STREAM``.
238
+ """
239
+ request = _Request(event)
240
+ env = request.environ()
241
+ env['lambda.event'] = event
242
+ env['lambda.context'] = context
243
+
244
+ tx = self.build_tx()
245
+ req = tx.req.parse(env)
246
+ tx.remote_address = request.remote_address
247
+ if request.body:
248
+ req.parse(request.body)
249
+
250
+ # The response, which the application can give later
251
+ resumed: list[bool] = []
252
+
253
+ def resume(tx: Pyjo.Transaction.object) -> None:
254
+ resumed.append(True)
255
+
256
+ tx.on('resume', resume)
257
+ self.emit('request', tx)
258
+ while not resumed:
259
+ Pyjo.IOLoop.one_tick()
260
+
261
+ # The body, which can come later and in chunks, without their framing
262
+ res = tx.res.fix_headers()
263
+ body = _IO(tx, tx.is_empty, res.content.is_chunked)
264
+
265
+ # The headers without the hop-by-hop ones, as the body is without its framing
266
+ headers = [(name, value) for name, value in res.headers.to_list() if not wsgiref.util.is_hop_by_hop(name)]
267
+ code = res.code or 404
268
+
269
+ # The body as it is written, after the status and the headers, where the events of an Application Load Balancer
270
+ # have no streaming
271
+ if self.invoke_mode == 'RESPONSE_STREAM' and not request.alb:
272
+ return _Stream(request.headers({'statusCode': code}, headers), body)
273
+
274
+ # The whole body
275
+ try:
276
+ content = b''.join(body)
277
+ finally:
278
+ body.close()
279
+ response = request.headers({'statusCode': code}, headers)
280
+ if request.alb:
281
+ response['statusDescription'] = f'{code} {res.message or res.default_message(code)}'
282
+
283
+ # Text as it is, other content in Base64
284
+ content_type = res.headers.content_type or ''
285
+ if not content or _RE_TEXT.match(content_type) and _is_utf8(content):
286
+ response['body'] = content.decode()
287
+ response['isBase64Encoded'] = False
288
+ else:
289
+ response['body'] = base64.b64encode(content).decode('ascii')
290
+ response['isBase64Encoded'] = True
291
+ return response
292
+
293
+ def run_runtime(self) -> None:
294
+ """::
295
+
296
+ server.run_runtime()
297
+
298
+ Run the application for the invocations of the Runtime API of
299
+ ``AWS_LAMBDA_RUNTIME_API``, one after another, as the runtime client
300
+ does with a handler, until the process ends, so that the script of
301
+ the application is the runtime of a custom runtime itself.
302
+ """
303
+ if not (api := os.environ.get('AWS_LAMBDA_RUNTIME_API')):
304
+ raise Pyjo.Exception.new('AWS_LAMBDA_RUNTIME_API is not set')
305
+
306
+ # Preload application
307
+ self.app.server(self)
308
+ runtime = _Runtime(api)
309
+ try:
310
+ runtime.loop(self.run)
311
+ finally:
312
+ runtime.connection.close()
313
+
314
+ def to_lambda_app(self) -> Handler:
315
+ """::
316
+
317
+ handler = server.to_lambda_app()
318
+
319
+ Turn :mod:`Pyjoyment` application into the handler of an event
320
+ function of AWS Lambda.
321
+ """
322
+ # Preload application and wrap it
323
+ self.app.server(self)
324
+
325
+ def handler(event: Event, context: Any) -> Response | Iterator[bytes]:
326
+ return self.run(event, context)
327
+
328
+ return handler
329
+
330
+
331
+ # The streaming response of an HTTP integration of Function URLs and API Gateway: the status and the headers as JSON, 8
332
+ # null bytes and the body as it is written
333
+ class _Stream(Iterator[bytes]):
334
+ content_type = 'application/vnd.awslambda.http-integration-response'
335
+
336
+ def __init__(self, response: Response, body: _IO) -> None:
337
+ self._prelude: bytes | None = json.dumps(response, separators=(',', ':')).encode() + b'\x00' * 8
338
+ self._body = body
339
+
340
+ def __iter__(self) -> Self:
341
+ return self
342
+
343
+ def __next__(self) -> bytes:
344
+ if (prelude := self._prelude) is not None:
345
+ self._prelude = None
346
+ return prelude
347
+ return next(self._body)
348
+
349
+ def close(self) -> None:
350
+ self._body.close()
351
+
352
+
353
+ # The request of an event, in one of its payload formats
354
+ class _Request:
355
+ def __init__(self, event: Event) -> None:
356
+ self.event = event
357
+ context: dict[str, Any] = event.get('requestContext') or {}
358
+ self.alb = 'elb' in context
359
+ self.v2 = event.get('version') == '2.0'
360
+ self.pairs: list[tuple[str, str]]
361
+
362
+ # The body, which can be in Base64
363
+ body: str | None = event.get('body')
364
+ if body is None:
365
+ self.body = b''
366
+ elif event.get('isBase64Encoded'):
367
+ self.body = base64.b64decode(body)
368
+ else:
369
+ self.body = body.encode()
370
+
371
+ # The format 2.0, of HTTP APIs and Function URLs
372
+ if self.v2:
373
+ http: dict[str, Any] = context.get('http') or {}
374
+ self.method: str = http.get('method') or 'GET'
375
+ self.path: str = event.get('rawPath') or '/'
376
+ self.query: str = event.get('rawQueryString') or ''
377
+ self.pairs = _pairs(event.get('headers'))
378
+ if cookies := event.get('cookies'):
379
+ self.pairs.append(('cookie', '; '.join(cookies)))
380
+ self.remote_address: str | None = http.get('sourceIp')
381
+ self.protocol: str | None = http.get('protocol')
382
+ self.https = True
383
+
384
+ # A stage other than the default one in front of the path
385
+ stage = context.get('stage')
386
+ prefix = f'/{stage}'
387
+ within = stage and stage != '$default' and (self.path == prefix or self.path.startswith(f'{prefix}/'))
388
+ self.base = prefix if within else None
389
+ return
390
+
391
+ # The format 1.0, of REST APIs, and Application Load Balancers
392
+ self.method = event.get('httpMethod') or 'GET'
393
+ self.path = event.get('path') or '/'
394
+ multi = event.get('multiValueHeaders')
395
+ self.pairs = _pairs(multi if multi is not None else event.get('headers'))
396
+ pairs = _pairs(event.get('multiValueQueryStringParameters') or event.get('queryStringParameters'))
397
+
398
+ # The parameters of an Application Load Balancer as they were sent, and the decoded ones of a REST API
399
+ if self.alb:
400
+ self.query = '&'.join(f'{name}={value}' for name, value in pairs)
401
+ else:
402
+ query = Pyjo.Parameters.new()
403
+ for name, value in pairs:
404
+ query.append(name, value)
405
+ self.query = query.to_str()
406
+ identity: dict[str, Any] = context.get('identity') or {}
407
+ self.remote_address = identity.get('sourceIp')
408
+ self.protocol = context.get('protocol')
409
+ self.https = any(name.lower() == 'x-forwarded-proto' and value == 'https' for name, value in self.pairs)
410
+
411
+ # The stage of a REST API in front of the path
412
+ path: str = context.get('path') or ''
413
+ self.base = path[: -len(self.path)] or None if path != self.path and path.endswith(self.path) else None
414
+
415
+ def environ(self) -> dict[str, Any]:
416
+ # The CGI environment that Pyjo.Message.Request parses, with a path whose bytes beyond ASCII are
417
+ # percent-encoded, as the path is escaped
418
+ env: dict[str, Any] = {
419
+ 'REQUEST_METHOD': self.method,
420
+ 'PATH_INFO': url_escape(self.path.encode(), rb'\x80-\xff').decode('ascii'),
421
+ 'QUERY_STRING': self.query,
422
+ 'SERVER_PROTOCOL': self.protocol or 'HTTP/1.1',
423
+ }
424
+ if self.base is not None:
425
+ env['SCRIPT_NAME'] = self.base
426
+ if self.https:
427
+ env['HTTPS'] = 'on'
428
+ for name, value in self.pairs:
429
+ key = 'HTTP_' + name.upper().replace('-', '_')
430
+
431
+ # The body is whole, without the chunked framing
432
+ if key == 'HTTP_TRANSFER_ENCODING':
433
+ continue
434
+ env[key] = f'{env[key]}{"; " if key == "HTTP_COOKIE" else ", "}{value}' if key in env else value
435
+ if self.body:
436
+ env['CONTENT_LENGTH'] = str(len(self.body))
437
+ return env
438
+
439
+ def headers(self, response: Response, pairs: list[tuple[str, str]]) -> Response:
440
+ # The format 2.0 with its cookies, and its other headers of multiple values with commas
441
+ if self.v2:
442
+ headers: dict[str, str] = {}
443
+ cookies: list[str] = []
444
+ for name, value in pairs:
445
+ if (name := name.lower()) == 'set-cookie':
446
+ cookies.append(value)
447
+ else:
448
+ headers[name] = f'{headers[name]},{value}' if name in headers else value
449
+ response['headers'] = headers
450
+ if cookies:
451
+ response['cookies'] = cookies
452
+
453
+ # The headers of multiple values of the event, or the last value of each header
454
+ elif 'multiValueHeaders' in self.event:
455
+ multi: dict[str, list[str]] = {}
456
+ for name, value in pairs:
457
+ multi.setdefault(name, []).append(value)
458
+ response['multiValueHeaders'] = multi
459
+ else:
460
+ response['headers'] = dict(pairs)
461
+ return response
462
+
463
+
464
+ def main(argv: Sequence[str] | None = None) -> NoReturn:
465
+ """::
466
+
467
+ main(['myapp.handler'])
468
+
469
+ The command line interface of the runtime client: parse the options and
470
+ run the handler for the invocations of the Runtime API.
471
+ """
472
+ args = list(sys.argv[1:] if argv is None else argv)
473
+ opts = getopt(args, {'h|help': None})
474
+ name = args[0] if args else os.environ.get('_HANDLER')
475
+ if not opts or opts.help or not name:
476
+ # The usage without its last newline, as SystemExit writes the message with one
477
+ die(extract_usage(__file__, section='Runtime client').rstrip('\n'))
478
+ if not (api := os.environ.get('AWS_LAMBDA_RUNTIME_API')):
479
+ die('AWS_LAMBDA_RUNTIME_API is not set')
480
+ runtime = _Runtime(api)
481
+ try:
482
+ runtime.run(name)
483
+ finally:
484
+ runtime.connection.close()
485
+ sys.exit(0)
486
+
487
+
488
+ # An invocation of the Runtime API, as the context of the Python runtime of Lambda has it
489
+ class _Context:
490
+ def __init__(self, headers: http.client.HTTPMessage) -> None:
491
+ self.aws_request_id = headers.get('Lambda-Runtime-Aws-Request-Id', '')
492
+ self.invoked_function_arn = headers.get('Lambda-Runtime-Invoked-Function-Arn', '')
493
+ self.function_name = os.environ.get('AWS_LAMBDA_FUNCTION_NAME')
494
+ self.function_version = os.environ.get('AWS_LAMBDA_FUNCTION_VERSION')
495
+ self.memory_limit_in_mb = os.environ.get('AWS_LAMBDA_FUNCTION_MEMORY_SIZE')
496
+ self.log_group_name = os.environ.get('AWS_LAMBDA_LOG_GROUP_NAME')
497
+ self.log_stream_name = os.environ.get('AWS_LAMBDA_LOG_STREAM_NAME')
498
+ self.identity = _json(headers.get('Lambda-Runtime-Cognito-Identity'))
499
+ self.client_context = _json(headers.get('Lambda-Runtime-Client-Context'))
500
+ deadline = headers.get('Lambda-Runtime-Deadline-Ms')
501
+ self.deadline = int(deadline) if deadline and deadline.isdigit() else None
502
+
503
+ def get_remaining_time_in_millis(self) -> int:
504
+ if self.deadline is None:
505
+ return 0
506
+ return max(self.deadline - int(time.time() * 1000), 0)
507
+
508
+
509
+ # The client of the Runtime API, with a connection of its own, outside of the event loop
510
+ class _Runtime:
511
+ PREFIX = '/2018-06-01/runtime'
512
+
513
+ def __init__(self, api: str) -> None:
514
+ self.connection = http.client.HTTPConnection(api)
515
+
516
+ def run(self, name: str) -> None:
517
+ try:
518
+ handler = _handler(name)
519
+ except Exception as e:
520
+ self.post(f'{self.PREFIX}/init/error', _error(e, 'Runtime.ImportModuleError'), 'Runtime.ImportModuleError')
521
+ raise SystemExit(1) from None
522
+ self.loop(handler)
523
+
524
+ # The invocations, one after another, for the handler
525
+ def loop(self, handler: Callable[[Any, Any], Any]) -> None:
526
+ while True:
527
+ self.connection.request('GET', f'{self.PREFIX}/invocation/next')
528
+ res = self.connection.getresponse()
529
+ event = json.loads(res.read() or b'null')
530
+ context = _Context(res.headers)
531
+ if trace := res.headers.get('Lambda-Runtime-Trace-Id'):
532
+ os.environ['_X_AMZN_TRACE_ID'] = trace
533
+ else:
534
+ os.environ.pop('_X_AMZN_TRACE_ID', None)
535
+ path = f'{self.PREFIX}/invocation/{context.aws_request_id}'
536
+ try:
537
+ result = handler(event, context)
538
+ if isinstance(result, Iterator):
539
+ self.stream(path, cast(Iterator[bytes], result))
540
+ continue
541
+ body = json.dumps(result).encode()
542
+ except Exception as e:
543
+ self.post(f'{path}/error', _error(e), 'Unhandled')
544
+ continue
545
+ self.post(f'{path}/response', body)
546
+
547
+ def post(self, path: str, body: bytes, error: str | None = None) -> None:
548
+ headers = {'Content-Type': 'application/json'}
549
+ if error is not None:
550
+ headers['Lambda-Runtime-Function-Error-Type'] = error
551
+ self.connection.request('POST', path, body, headers)
552
+ self.connection.getresponse().read()
553
+
554
+ # A streaming response, chunked as it is written, with an exception in the middle of it in the trailers, and the
555
+ # connection closed after it, as the Runtime API asks
556
+ def stream(self, path: str, body: Iterator[bytes]) -> None:
557
+ try:
558
+ # The first chunk before the request, so that an exception before the response is the error of the
559
+ # invocation, which the Runtime API takes only before it
560
+ try:
561
+ chunk = next(body, b'')
562
+ except Exception as e:
563
+ self.post(f'{path}/error', _error(e), 'Unhandled')
564
+ return
565
+
566
+ connection = self.connection
567
+ connection.putrequest('POST', f'{path}/response', skip_accept_encoding=True)
568
+ connection.putheader('Content-Type', getattr(body, 'content_type', 'application/octet-stream'))
569
+ connection.putheader('Lambda-Runtime-Function-Response-Mode', 'streaming')
570
+ connection.putheader('Transfer-Encoding', 'chunked')
571
+ connection.putheader('Trailer', 'Lambda-Runtime-Function-Error-Type, Lambda-Runtime-Function-Error-Body')
572
+ connection.endheaders()
573
+ trailers = b''
574
+ try:
575
+ while True:
576
+ if chunk:
577
+ connection.send(b'%x\x0d\x0a%s\x0d\x0a' % (len(chunk), chunk))
578
+ chunk = next(body)
579
+ except StopIteration:
580
+ pass
581
+ except Exception as e:
582
+ error = base64.b64encode(_error(e)).decode('ascii')
583
+ trailers = (
584
+ f'Lambda-Runtime-Function-Error-Type: Unhandled\x0d\x0a'
585
+ f'Lambda-Runtime-Function-Error-Body: {error}\x0d\x0a'
586
+ ).encode()
587
+ connection.send(b'0\x0d\x0a' + trailers + b'\x0d\x0a')
588
+ connection.getresponse().read()
589
+ connection.close()
590
+ finally:
591
+ if close := getattr(body, 'close', None):
592
+ close()
593
+
594
+
595
+ # The error of an exception, as the Python runtime of Lambda posts it
596
+ def _error(e: Exception, type: str | None = None) -> bytes:
597
+ stack = traceback.format_list(traceback.extract_tb(e.__traceback__))
598
+ return json.dumps({'errorMessage': str(e), 'errorType': type or e.__class__.__name__, 'stackTrace': stack}).encode()
599
+
600
+
601
+ # The handler of a name "module.function", from the directory of the task first
602
+ def _handler(name: str) -> Callable[[Any, Any], Any]:
603
+ module, _, function = name.rpartition('.')
604
+ if not module:
605
+ raise ValueError(f'Bad handler "{name}": not a module and a function')
606
+ root = os.environ.get('LAMBDA_TASK_ROOT') or os.getcwd()
607
+ if root not in sys.path:
608
+ sys.path.insert(0, root)
609
+ handler = getattr(importlib.import_module(module), function, None)
610
+ if not callable(handler):
611
+ raise AttributeError(f'Handler "{function}" missing on module "{module}"')
612
+ return handler
613
+
614
+
615
+ def _json(value: str | None) -> Any:
616
+ return json.loads(value) if value else None
617
+
618
+
619
+ def _is_utf8(body: bytes) -> bool:
620
+ try:
621
+ body.decode()
622
+ except UnicodeDecodeError:
623
+ return False
624
+ return True
625
+
626
+
627
+ # The pairs of names and values of a dict of the event, whose values can be lists of values
628
+ def _pairs(values: Any) -> list[tuple[str, str]]:
629
+ pairs: list[tuple[str, str]] = []
630
+ for name, value in cast(dict[str, Any], values or {}).items():
631
+ for item in cast(list[Any], value) if isinstance(value, list) else [value]:
632
+ pairs.append((name, str(item)))
633
+ return pairs
634
+
635
+
636
+ new = Pyjo_Server_Lambda.new
637
+ object = Pyjo_Server_Lambda
638
+ Lambda = Pyjo_Server_Lambda
639
+
640
+ if __name__ == '__main__':
641
+ main()