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.
- Pyjo/App/ASGI.py +735 -0
- Pyjo/App/CGI.py +773 -0
- Pyjo/App/FastCGI.py +631 -0
- Pyjo/App/Lambda.py +987 -0
- Pyjo/App/WSGI.py +427 -0
- Pyjo/App/__init__.py +409 -0
- Pyjo/Asset/File.py +377 -0
- Pyjo/Asset/Memory.py +235 -0
- Pyjo/Asset/__init__.py +200 -0
- Pyjo/Base.py +215 -0
- Pyjo/Cache.py +119 -0
- Pyjo/Collection.py +570 -0
- Pyjo/Content/MultiPart.py +325 -0
- Pyjo/Content/Single.py +193 -0
- Pyjo/Content/__init__.py +806 -0
- Pyjo/Cookie/Request.py +90 -0
- Pyjo/Cookie/Response.py +294 -0
- Pyjo/Cookie/__init__.py +143 -0
- Pyjo/DOM/CSS.py +1019 -0
- Pyjo/DOM/HTML.py +550 -0
- Pyjo/DOM/__init__.py +1329 -0
- Pyjo/Date.py +294 -0
- Pyjo/EventEmitter.py +522 -0
- Pyjo/Exception.py +494 -0
- Pyjo/File.py +959 -0
- Pyjo/GetOpt.py +605 -0
- Pyjo/Headers.py +760 -0
- Pyjo/HelloWorld.py +77 -0
- Pyjo/Home.py +109 -0
- Pyjo/IOLoop/ChildProcess.py +591 -0
- Pyjo/IOLoop/Client.py +736 -0
- Pyjo/IOLoop/Datagram.py +982 -0
- Pyjo/IOLoop/Messages.py +664 -0
- Pyjo/IOLoop/Server.py +458 -0
- Pyjo/IOLoop/Stdio.py +446 -0
- Pyjo/IOLoop/Stream.py +644 -0
- Pyjo/IOLoop/Subprocess.py +419 -0
- Pyjo/IOLoop/TLS.py +360 -0
- Pyjo/IOLoop/__init__.py +1036 -0
- Pyjo/IOLoop/certs/README.md +41 -0
- Pyjo/IOLoop/certs/ca.crt +20 -0
- Pyjo/IOLoop/certs/server.crt +21 -0
- Pyjo/IOLoop/certs/server.key +28 -0
- Pyjo/JSON/Pointer.py +135 -0
- Pyjo/JSON/RPC.py +772 -0
- Pyjo/JSON/Schema.py +2506 -0
- Pyjo/JSON/__init__.py +573 -0
- Pyjo/Loader.py +342 -0
- Pyjo/Log.py +723 -0
- Pyjo/Message/Request.py +725 -0
- Pyjo/Message/Response.py +476 -0
- Pyjo/Message/__init__.py +830 -0
- Pyjo/Parameters.py +472 -0
- Pyjo/Path.py +419 -0
- Pyjo/Promise.py +894 -0
- Pyjo/Prove.py +1305 -0
- Pyjo/Reactor/Asyncio.py +854 -0
- Pyjo/Reactor/Poll.py +439 -0
- Pyjo/Reactor/__init__.py +525 -0
- Pyjo/SSE.py +119 -0
- Pyjo/Server/ASGI.py +486 -0
- Pyjo/Server/CGI.py +259 -0
- Pyjo/Server/Daemon.py +1007 -0
- Pyjo/Server/FastCGI.py +517 -0
- Pyjo/Server/Hot/__init__.py +687 -0
- Pyjo/Server/Hot/__main__.py +5 -0
- Pyjo/Server/Lambda.py +641 -0
- Pyjo/Server/Prefork.py +917 -0
- Pyjo/Server/Prethread.py +394 -0
- Pyjo/Server/Reload/Backend/Poll.py +114 -0
- Pyjo/Server/Reload/Backend/__init__.py +126 -0
- Pyjo/Server/Reload/__init__.py +482 -0
- Pyjo/Server/Reload/__main__.py +5 -0
- Pyjo/Server/Stdio.py +228 -0
- Pyjo/Server/WSGI.py +349 -0
- Pyjo/Server/__init__.py +396 -0
- Pyjo/String/Bytes.py +532 -0
- Pyjo/String/HTML.py +210 -0
- Pyjo/String/Mixin.py +333 -0
- Pyjo/String/Template.py +470 -0
- Pyjo/String/Unicode.py +553 -0
- Pyjo/String/__init__.py +0 -0
- Pyjo/TOML.py +232 -0
- Pyjo/Template.py +1290 -0
- Pyjo/Test/Pyjo.py +1454 -0
- Pyjo/Test/__init__.py +1233 -0
- Pyjo/Test/pytest_plugin.py +305 -0
- Pyjo/Transaction/HTTP.py +374 -0
- Pyjo/Transaction/WebSocket.py +709 -0
- Pyjo/Transaction/__init__.py +429 -0
- Pyjo/URL/Template.py +459 -0
- Pyjo/URL/__init__.py +651 -0
- Pyjo/Upload.py +139 -0
- Pyjo/UserAgent/CookieJar.py +391 -0
- Pyjo/UserAgent/Proxy.py +138 -0
- Pyjo/UserAgent/Server.py +239 -0
- Pyjo/UserAgent/Transactor.py +766 -0
- Pyjo/UserAgent/__init__.py +1243 -0
- Pyjo/Util.py +3567 -0
- Pyjo/WebSocket.py +332 -0
- Pyjo/YAML.py +1167 -0
- Pyjo/__init__.py +441 -0
- Pyjo/__main__.py +35 -0
- Pyjo/py.typed +0 -0
- Pyjoyment/Command/Author/__init__.py +20 -0
- Pyjoyment/Command/Author/generate/__init__.py +106 -0
- Pyjoyment/Command/Author/generate/app.py +299 -0
- Pyjoyment/Command/Author/generate/dockerfile.py +281 -0
- Pyjoyment/Command/Author/generate/kubernetes.py +514 -0
- Pyjoyment/Command/Author/generate/lambda_zip.py +372 -0
- Pyjoyment/Command/Author/generate/lite_app.py +113 -0
- Pyjoyment/Command/Author/generate/plugin.py +267 -0
- Pyjoyment/Command/Author/generate/pyproject.py +123 -0
- Pyjoyment/Command/Author/inflate.py +101 -0
- Pyjoyment/Command/__init__.py +332 -0
- Pyjoyment/Command/asgi.py +92 -0
- Pyjoyment/Command/cgi.py +90 -0
- Pyjoyment/Command/daemon.py +160 -0
- Pyjoyment/Command/eval.py +128 -0
- Pyjoyment/Command/fastcgi.py +99 -0
- Pyjoyment/Command/get.py +305 -0
- Pyjoyment/Command/lambda.py +117 -0
- Pyjoyment/Command/prefork.py +181 -0
- Pyjoyment/Command/prethread.py +126 -0
- Pyjoyment/Command/routes.py +133 -0
- Pyjoyment/Command/stdio.py +92 -0
- Pyjoyment/Command/version.py +141 -0
- Pyjoyment/Command/wsgi.py +93 -0
- Pyjoyment/Commands.py +546 -0
- Pyjoyment/Controller.py +1307 -0
- Pyjoyment/HelloWorld.py +56 -0
- Pyjoyment/Lite.py +494 -0
- Pyjoyment/Plugin/AllowedHosts.py +205 -0
- Pyjoyment/Plugin/Config.py +248 -0
- Pyjoyment/Plugin/DefaultHelpers.py +1202 -0
- Pyjoyment/Plugin/EPLRenderer.py +134 -0
- Pyjoyment/Plugin/EPRenderer.py +212 -0
- Pyjoyment/Plugin/HeaderCondition.py +98 -0
- Pyjoyment/Plugin/Health.py +325 -0
- Pyjoyment/Plugin/JSONConfig.py +172 -0
- Pyjoyment/Plugin/Mount.py +223 -0
- Pyjoyment/Plugin/PyRenderer.py +209 -0
- Pyjoyment/Plugin/TOMLConfig.py +141 -0
- Pyjoyment/Plugin/TagHelpers.py +959 -0
- Pyjoyment/Plugin/YAMLConfig.py +178 -0
- Pyjoyment/Plugin/__init__.py +80 -0
- Pyjoyment/Plugins.py +215 -0
- Pyjoyment/Renderer.py +673 -0
- Pyjoyment/Routes/Match.py +251 -0
- Pyjoyment/Routes/Pattern.py +443 -0
- Pyjoyment/Routes/Route.py +827 -0
- Pyjoyment/Routes/__init__.py +419 -0
- Pyjoyment/Sessions.py +255 -0
- Pyjoyment/Static.py +421 -0
- Pyjoyment/Types.py +238 -0
- Pyjoyment/Validator/Validation.py +395 -0
- Pyjoyment/Validator/__init__.py +304 -0
- Pyjoyment/__init__.py +1083 -0
- Pyjoyment/__main__.py +5 -0
- Pyjoyment/py.typed +0 -0
- Pyjoyment/resources/public/favicon.ico +0 -0
- Pyjoyment/resources/public/favicon.svg +345 -0
- Pyjoyment/resources/public/pyjo/bootstrap/LICENSE +21 -0
- Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.bundle.min.js.map +1 -0
- Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.css +6 -0
- Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.js +7 -0
- Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.min.css.map +1 -0
- Pyjoyment/resources/public/pyjo/failunicat.png +0 -0
- Pyjoyment/resources/public/pyjo/failunicat.svg +50 -0
- Pyjoyment/resources/public/pyjo/highlight.js/LICENSE +29 -0
- Pyjoyment/resources/public/pyjo/highlight.js/highlight-mojo-dark.css +56 -0
- Pyjoyment/resources/public/pyjo/highlight.js/highlight.min.js +1262 -0
- Pyjoyment/resources/public/pyjo/highlight.js/pyjoyment.min.js +1 -0
- Pyjoyment/resources/public/pyjo/logo-black-4x.png +0 -0
- Pyjoyment/resources/public/pyjo/logo-black-4x.svg +346 -0
- Pyjoyment/resources/public/pyjo/logo-white-2x.png +0 -0
- Pyjoyment/resources/public/pyjo/logo-white-2x.svg +346 -0
- Pyjoyment/resources/public/pyjo/logo-white-4x.png +0 -0
- Pyjoyment/resources/public/pyjo/logo-white-4x.svg +346 -0
- Pyjoyment/resources/public/pyjo/logo-white.png +0 -0
- Pyjoyment/resources/public/pyjo/logo-white.svg +346 -0
- Pyjoyment/resources/public/pyjo/logo.png +0 -0
- Pyjoyment/resources/public/pyjo/logo.svg +346 -0
- Pyjoyment/resources/public/pyjo/notfound.png +0 -0
- Pyjoyment/resources/public/pyjo/notfound.svg +3 -0
- Pyjoyment/resources/public/pyjo/nounicat.png +0 -0
- Pyjoyment/resources/public/pyjo/nounicat.svg +5 -0
- Pyjoyment/resources/public/pyjo/pinstripe-dark.png +0 -0
- Pyjoyment/resources/public/pyjo/pinstripe-dark.svg +4 -0
- Pyjoyment/resources/public/pyjo/pinstripe-light.png +0 -0
- Pyjoyment/resources/public/pyjo/pinstripe-light.svg +4 -0
- Pyjoyment/resources/public/pyjo/pyjo.css +204 -0
- Pyjoyment/resources/templates/pyjo/debug.html.ep +346 -0
- Pyjoyment/resources/templates/pyjo/exception.html.ep +32 -0
- Pyjoyment/resources/templates/pyjo/not_found.html.ep +45 -0
- pyjoyment-0.1.0.dist-info/METADATA +376 -0
- pyjoyment-0.1.0.dist-info/RECORD +200 -0
- pyjoyment-0.1.0.dist-info/WHEEL +4 -0
- pyjoyment-0.1.0.dist-info/entry_points.txt +8 -0
- pyjoyment-0.1.0.dist-info/licenses/LICENSE +201 -0
Pyjo/App/Lambda.py
ADDED
|
@@ -0,0 +1,987 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pyjo.App.Lambda - AWS Lambda event function
|
|
3
|
+
===========================================
|
|
4
|
+
::
|
|
5
|
+
|
|
6
|
+
from Pyjo.App.Lambda import Lambda
|
|
7
|
+
|
|
8
|
+
app = Lambda(function='myfunction.handler')
|
|
9
|
+
|
|
10
|
+
# Or import the module, as in Google Python Style Guide
|
|
11
|
+
|
|
12
|
+
import Pyjo.App.Lambda
|
|
13
|
+
|
|
14
|
+
app = Pyjo.App.Lambda.new(function='myfunction.handler')
|
|
15
|
+
|
|
16
|
+
# Another runtime, with the events of a REST API
|
|
17
|
+
app = Pyjo.App.Lambda.new(command=['node', 'runtime.js'], payload='1.0')
|
|
18
|
+
|
|
19
|
+
# Mount it in an application of Pyjoyment
|
|
20
|
+
import Pyjoyment
|
|
21
|
+
|
|
22
|
+
pyjoyment = Pyjoyment.new()
|
|
23
|
+
pyjoyment.plugin('Mount', {'/function': app})
|
|
24
|
+
|
|
25
|
+
:mod:`Pyjo.App.Lambda` is an application of :mod:`Pyjo` that runs an event
|
|
26
|
+
function of `AWS Lambda <https://aws.amazon.com/lambda/>`_, so that a server
|
|
27
|
+
of :mod:`Pyjo` serves it, a route of :mod:`Pyjoyment` embeds it, as
|
|
28
|
+
:mod:`Pyjoyment.Plugin.Mount` does, and :mod:`Pyjo.Test.Pyjo` tests it, as
|
|
29
|
+
API Gateway, a Function URL or an Application Load Balancer runs it on AWS.
|
|
30
|
+
|
|
31
|
+
The function runs in a process of its own, as an execution environment of
|
|
32
|
+
Lambda does, which :mod:`Pyjo.IOLoop.ChildProcess` starts on the first
|
|
33
|
+
request and keeps for the next ones, which it gets one after another. Each
|
|
34
|
+
event loop has a process of its own, and the child of a fork, as a worker of
|
|
35
|
+
:mod:`Pyjo.Server.Prefork`, leaves the processes of its parent to it and
|
|
36
|
+
starts its own. The process gets its invocations from the `Runtime API
|
|
37
|
+
<https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html>`_ of
|
|
38
|
+
``AWS_LAMBDA_RUNTIME_API``, which a :mod:`Pyjo.Server.Daemon` on a port of
|
|
39
|
+
the loopback serves on the same event loop, so that a handler of Python runs
|
|
40
|
+
with the runtime client of :mod:`Pyjo.Server.Lambda`, also one that runs an
|
|
41
|
+
event loop of its own, and a function of another runtime with its own runtime
|
|
42
|
+
client, without the event loop waiting for it.
|
|
43
|
+
|
|
44
|
+
The request is the event of the payload format of :attr:`payload`, as
|
|
45
|
+
:mod:`Pyjo.Server.Lambda` takes it, and the path that a route embeds the
|
|
46
|
+
application under is the stage of API Gateway, in front of the path, which
|
|
47
|
+
:mod:`Pyjo.Server.Lambda` takes for the base path of the URL. A body that is
|
|
48
|
+
not text, JSON, XML, JavaScript or YAML in UTF-8 is in Base64. The response is
|
|
49
|
+
the one of the payload format, with its ``statusCode``, its headers, its
|
|
50
|
+
cookies and its body, which can be in Base64, and in the format ``2.0`` a
|
|
51
|
+
value without ``statusCode`` is a response ``200`` of the value as JSON, as API
|
|
52
|
+
Gateway takes it.
|
|
53
|
+
|
|
54
|
+
A streaming response of the runtime client, as the one of
|
|
55
|
+
:mod:`Pyjo.Server.Lambda` with the invoke mode ``RESPONSE_STREAM``, is
|
|
56
|
+
written as it comes, as a Function URL with the invoke mode
|
|
57
|
+
``RESPONSE_STREAM`` writes it: the status and the headers of its prelude, in
|
|
58
|
+
the content type ``application/vnd.awslambda.http-integration-response``,
|
|
59
|
+
before 8 null bytes, or ``200`` and the content type of the response, and its
|
|
60
|
+
body in chunks. The timeout of the function goes on until its end, and an
|
|
61
|
+
error in its trailers, or the timeout, in the middle of it closes the
|
|
62
|
+
connection, as the response cannot change anymore. ::
|
|
63
|
+
|
|
64
|
+
# The streaming responses of an application on Pyjo.Server.Lambda
|
|
65
|
+
app = Pyjo.App.Lambda.new(function='myapp.handler', env={'PYJO_LAMBDA_INVOKE_MODE': 'RESPONSE_STREAM'})
|
|
66
|
+
|
|
67
|
+
An error of the function or of its initialization, a response that is not
|
|
68
|
+
valid, a process that ends before its response, and an invocation that takes
|
|
69
|
+
longer than :attr:`timeout`, whose process is killed, as Lambda does, are a
|
|
70
|
+
response ``502``, logged, and the next request starts a new process. The
|
|
71
|
+
output of the process is logged, as Lambda sends it to CloudWatch. The event
|
|
72
|
+
loop does not watch pipes on Windows, so the response is ``502`` there.
|
|
73
|
+
|
|
74
|
+
Attributes
|
|
75
|
+
----------
|
|
76
|
+
|
|
77
|
+
:mod:`Pyjo.App.Lambda` inherits all attributes from :mod:`Pyjo.App` and
|
|
78
|
+
implements the following new ones.
|
|
79
|
+
|
|
80
|
+
See also
|
|
81
|
+
--------
|
|
82
|
+
|
|
83
|
+
:mod:`Pyjoyment`, :mod:`Pyjoyment.Plugin.Mount`, :mod:`Pyjo.Server.Lambda`,
|
|
84
|
+
:mod:`Pyjo.App.CGI`, https://mojolicious.org.
|
|
85
|
+
|
|
86
|
+
It has no counterpart in Mojolicious; it is the reverse of
|
|
87
|
+
:mod:`Pyjo.Server.Lambda`.
|
|
88
|
+
|
|
89
|
+
Classes
|
|
90
|
+
-------
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
import Pyjo.App
|
|
94
|
+
import Pyjo.Exception
|
|
95
|
+
import Pyjo.IOLoop
|
|
96
|
+
import Pyjo.Server.Daemon
|
|
97
|
+
import Pyjo.Transaction
|
|
98
|
+
import Pyjo.Transaction.HTTP
|
|
99
|
+
|
|
100
|
+
from Pyjo.Server.Lambda import _RE_TEXT, _is_utf8 # pyright: ignore[reportPrivateUsage]
|
|
101
|
+
|
|
102
|
+
import base64
|
|
103
|
+
import json
|
|
104
|
+
import os
|
|
105
|
+
import re
|
|
106
|
+
import signal
|
|
107
|
+
import sys
|
|
108
|
+
import time
|
|
109
|
+
import uuid
|
|
110
|
+
import weakref
|
|
111
|
+
import wsgiref.util
|
|
112
|
+
|
|
113
|
+
from collections import deque
|
|
114
|
+
from typing import Any, cast
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# The account, the region and the API of the identifiers of the events, which no AWS has
|
|
118
|
+
ACCOUNT = '123456789012'
|
|
119
|
+
REGION = 'us-east-1'
|
|
120
|
+
API = 'pyjo'
|
|
121
|
+
|
|
122
|
+
PREFIX = '/2018-06-01/runtime'
|
|
123
|
+
|
|
124
|
+
# The streaming response of an HTTP integration: the status and the headers as JSON, in the first 16 KB, before 8 null
|
|
125
|
+
# bytes and the body
|
|
126
|
+
INTEGRATION = 'application/vnd.awslambda.http-integration-response'
|
|
127
|
+
DELIMITER = b'\x00' * 8
|
|
128
|
+
MAX_PRELUDE = 16384
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Pyjo_App_Lambda(Pyjo.App.object):
|
|
132
|
+
"""
|
|
133
|
+
:mod:`Pyjo.App.Lambda` inherits all methods from :mod:`Pyjo.App` and
|
|
134
|
+
implements the following new ones.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self, attrs: dict[str, Any] | None = None, /, **kwargs: Any) -> None:
|
|
138
|
+
"""::
|
|
139
|
+
|
|
140
|
+
app = Pyjo.App.Lambda.new(function='myfunction.handler')
|
|
141
|
+
|
|
142
|
+
Construct a new :mod:`Pyjo.App.Lambda` object.
|
|
143
|
+
"""
|
|
144
|
+
self.command: list[str] | None = None
|
|
145
|
+
"""::
|
|
146
|
+
|
|
147
|
+
command = app.command
|
|
148
|
+
app.command = ['python', '-m', 'awslambdaric', 'myfunction.handler']
|
|
149
|
+
app.command = ['./bootstrap']
|
|
150
|
+
|
|
151
|
+
Program of the function, with its arguments, as the runtime client of
|
|
152
|
+
another runtime or the ``bootstrap`` of a custom runtime, in
|
|
153
|
+
:attr:`task_root`, or the runtime client of :mod:`Pyjo.Server.Lambda`
|
|
154
|
+
with :attr:`function` and the interpreter of this process.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
self.env: dict[str, str] = {}
|
|
158
|
+
"""::
|
|
159
|
+
|
|
160
|
+
env = app.env
|
|
161
|
+
app.env = {'TABLE_NAME': 'users'}
|
|
162
|
+
|
|
163
|
+
Variables of the environment of the function, which are added to the
|
|
164
|
+
ones of this process and of Lambda, and replace them.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
self.function: str | None = None
|
|
168
|
+
"""::
|
|
169
|
+
|
|
170
|
+
handler = app.function
|
|
171
|
+
app.function = 'myfunction.handler'
|
|
172
|
+
|
|
173
|
+
Handler of the function, ``module.function``, in ``_HANDLER``, which
|
|
174
|
+
the runtime client of :mod:`Pyjo.Server.Lambda` runs without
|
|
175
|
+
:attr:`command`.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
self.function_name = 'pyjo'
|
|
179
|
+
"""::
|
|
180
|
+
|
|
181
|
+
name = app.function_name
|
|
182
|
+
app.function_name = 'myfunction'
|
|
183
|
+
|
|
184
|
+
Name of the function, in ``AWS_LAMBDA_FUNCTION_NAME`` and in its ARN,
|
|
185
|
+
defaults to ``pyjo``.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
self.memory_size = 128
|
|
189
|
+
"""::
|
|
190
|
+
|
|
191
|
+
size = app.memory_size
|
|
192
|
+
app.memory_size = 512
|
|
193
|
+
|
|
194
|
+
Memory of the function in megabytes, in
|
|
195
|
+
``AWS_LAMBDA_FUNCTION_MEMORY_SIZE``, defaults to ``128``.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
self.multi_value_headers = False
|
|
199
|
+
"""::
|
|
200
|
+
|
|
201
|
+
boolean = app.multi_value_headers
|
|
202
|
+
app.multi_value_headers = True
|
|
203
|
+
|
|
204
|
+
The headers and the parameters of the query of the events of an
|
|
205
|
+
Application Load Balancer with multiple values, as a target group with
|
|
206
|
+
them has them, false by default.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
self.payload = '2.0'
|
|
210
|
+
"""::
|
|
211
|
+
|
|
212
|
+
payload = app.payload
|
|
213
|
+
app.payload = '1.0'
|
|
214
|
+
app.payload = 'alb'
|
|
215
|
+
|
|
216
|
+
Payload format of the events, ``2.0`` of HTTP APIs and Function URLs,
|
|
217
|
+
the default, ``1.0`` of REST APIs, or ``alb`` of Application Load
|
|
218
|
+
Balancers.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
self.task_root: str | os.PathLike[str] | None = None
|
|
222
|
+
"""::
|
|
223
|
+
|
|
224
|
+
root = app.task_root
|
|
225
|
+
app.task_root = '/var/task'
|
|
226
|
+
|
|
227
|
+
Directory of the function, in ``LAMBDA_TASK_ROOT``, which its process
|
|
228
|
+
runs in, and which the runtime client of :mod:`Pyjo.Server.Lambda`
|
|
229
|
+
imports the handler from, the current one by default.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
self.timeout: float = 30
|
|
233
|
+
"""::
|
|
234
|
+
|
|
235
|
+
timeout = app.timeout
|
|
236
|
+
app.timeout = 3
|
|
237
|
+
|
|
238
|
+
Maximum amount of time in seconds an invocation can take before its
|
|
239
|
+
process is killed, defaults to ``30``, as an integration of API
|
|
240
|
+
Gateway has it.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
# The execution environments of each event loop, of the process that made them
|
|
244
|
+
self._pid = os.getpid()
|
|
245
|
+
self._environments: weakref.WeakKeyDictionary[Any, _Environment] = weakref.WeakKeyDictionary()
|
|
246
|
+
_apps.add(self)
|
|
247
|
+
|
|
248
|
+
super().__init__(attrs, **kwargs)
|
|
249
|
+
|
|
250
|
+
def close(self) -> None:
|
|
251
|
+
"""::
|
|
252
|
+
|
|
253
|
+
app.close()
|
|
254
|
+
|
|
255
|
+
End the processes of the function, and close the connections and the
|
|
256
|
+
servers of :attr:`ua`, as the end of a ``with`` block does.
|
|
257
|
+
"""
|
|
258
|
+
if self._pid == os.getpid():
|
|
259
|
+
for environment in list(self._environments.values()):
|
|
260
|
+
environment.close()
|
|
261
|
+
self._environments.clear()
|
|
262
|
+
super().close()
|
|
263
|
+
|
|
264
|
+
def handler(self, tx: Any) -> None:
|
|
265
|
+
"""::
|
|
266
|
+
|
|
267
|
+
app.handler(Pyjo.Transaction.HTTP.new())
|
|
268
|
+
app.handler(Pyjoyment.Controller.new())
|
|
269
|
+
|
|
270
|
+
Invoke the function with the event of the request of the transaction,
|
|
271
|
+
or of the controller of a route that embeds the application, and write
|
|
272
|
+
its response.
|
|
273
|
+
"""
|
|
274
|
+
if self.function is None and self.command is None:
|
|
275
|
+
raise Pyjo.Exception.new('No Lambda function')
|
|
276
|
+
if self.payload not in ('2.0', '1.0', 'alb'):
|
|
277
|
+
raise Pyjo.Exception.new(f'Invalid Lambda payload format "{self.payload}"')
|
|
278
|
+
c = None
|
|
279
|
+
if not isinstance(tx, Pyjo.Transaction.object):
|
|
280
|
+
c = tx
|
|
281
|
+
tx = c.tx
|
|
282
|
+
if not Pyjo.App._within(c): # pyright: ignore[reportPrivateUsage]
|
|
283
|
+
return c.helpers.reply.not_found()
|
|
284
|
+
c.render_later()
|
|
285
|
+
_Invocation(self, tx, c).start()
|
|
286
|
+
|
|
287
|
+
def _environment(self) -> '_Environment':
|
|
288
|
+
loop = Pyjo.IOLoop.singleton
|
|
289
|
+
|
|
290
|
+
self._leave()
|
|
291
|
+
environment = self._environments.get(loop)
|
|
292
|
+
if environment is None or environment.ended:
|
|
293
|
+
_register()
|
|
294
|
+
self._environments[loop] = environment = _Environment(self)
|
|
295
|
+
return environment
|
|
296
|
+
|
|
297
|
+
# The child of a fork leaves the processes of its parent to it, and closes the copies of their servers, at the fork,
|
|
298
|
+
# before its event loop reads the connections of the runtime clients of the parent, or of the processes that the
|
|
299
|
+
# child starts later
|
|
300
|
+
def _leave(self) -> None:
|
|
301
|
+
if self._pid != (pid := os.getpid()):
|
|
302
|
+
for environment in list(self._environments.values()):
|
|
303
|
+
environment.leave()
|
|
304
|
+
self._pid, self._environments = pid, weakref.WeakKeyDictionary()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# An invocation of the function for a request, and its response
|
|
308
|
+
class _Invocation:
|
|
309
|
+
def __init__(self, app: Pyjo_App_Lambda, tx: Any, c: Any) -> None:
|
|
310
|
+
self.app = app
|
|
311
|
+
self.tx = tx
|
|
312
|
+
self.c = c
|
|
313
|
+
self.id = str(uuid.uuid4())
|
|
314
|
+
self.done = False
|
|
315
|
+
self.environment: _Environment | None = None
|
|
316
|
+
self.event: dict[str, Any] = {}
|
|
317
|
+
self.waiting: tuple[str, float] | None = None
|
|
318
|
+
|
|
319
|
+
# A streaming response: the request of the runtime client that sends it, the prelude that comes before its body,
|
|
320
|
+
# its status and its headers, which go with its first chunk, whether they have gone, with the body chunked, and
|
|
321
|
+
# the chunks that wait to be written, after which the connection closes when it is closing
|
|
322
|
+
self.api: str | None = None
|
|
323
|
+
self.prelude: bytearray | None = None
|
|
324
|
+
self.head: tuple[int, list[tuple[str, str]]] | None = None
|
|
325
|
+
self.started = False
|
|
326
|
+
self.chunked = False
|
|
327
|
+
self.pending = 0
|
|
328
|
+
self.closing = False
|
|
329
|
+
|
|
330
|
+
def start(self) -> None:
|
|
331
|
+
self.tx.on('finish', self._finish)
|
|
332
|
+
self.event = _event(self.app, self.tx, self.c, self.id)
|
|
333
|
+
|
|
334
|
+
# The connection of the client waits for the response without its inactivity timeout, which the timeout of the
|
|
335
|
+
# function takes the place of
|
|
336
|
+
if (connection := self.tx.connection) is not None and (stream := Pyjo.IOLoop.stream(connection)) is not None:
|
|
337
|
+
self.waiting = (connection, stream.timeout)
|
|
338
|
+
stream.timeout = 0
|
|
339
|
+
|
|
340
|
+
self.app._environment().invoke(self) # pyright: ignore[reportPrivateUsage]
|
|
341
|
+
|
|
342
|
+
def fail(self, message: str) -> None:
|
|
343
|
+
if self.done:
|
|
344
|
+
return
|
|
345
|
+
self.app.log.error(message)
|
|
346
|
+
|
|
347
|
+
# A streaming response that has started cannot change, so the connection is closed once the chunks of it that
|
|
348
|
+
# wait have been written
|
|
349
|
+
if self.started:
|
|
350
|
+
self.done = True
|
|
351
|
+
self._waited()
|
|
352
|
+
if self.pending:
|
|
353
|
+
self.closing = True
|
|
354
|
+
else:
|
|
355
|
+
self._close()
|
|
356
|
+
return
|
|
357
|
+
self._write(502, [('Content-Type', 'text/plain')], b'Bad Gateway')
|
|
358
|
+
|
|
359
|
+
# The response of the payload format, or a value as JSON of the format 2.0
|
|
360
|
+
def respond(self, body: bytes) -> None:
|
|
361
|
+
if self.done:
|
|
362
|
+
return
|
|
363
|
+
try:
|
|
364
|
+
value = json.loads(body or b'null')
|
|
365
|
+
except ValueError:
|
|
366
|
+
return self.fail('Lambda function returned invalid JSON')
|
|
367
|
+
if not isinstance(value, dict) or 'statusCode' not in value:
|
|
368
|
+
if self.app.payload != '2.0':
|
|
369
|
+
return self.fail('Lambda function returned no statusCode')
|
|
370
|
+
text = value if isinstance(value, str) else json.dumps(value)
|
|
371
|
+
return self._write(200, [('Content-Type', 'application/json')], text.encode())
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
response = cast(dict[str, Any], value)
|
|
375
|
+
code, headers = _status(response)
|
|
376
|
+
content = response.get('body')
|
|
377
|
+
if content is None:
|
|
378
|
+
data = b''
|
|
379
|
+
elif response.get('isBase64Encoded'):
|
|
380
|
+
data = base64.b64decode(content)
|
|
381
|
+
else:
|
|
382
|
+
data = str(content).encode()
|
|
383
|
+
except (AttributeError, TypeError, ValueError) as e:
|
|
384
|
+
return self.fail(f'Lambda function returned an invalid response: {e}')
|
|
385
|
+
self._write(code, headers, data)
|
|
386
|
+
|
|
387
|
+
# The start of a streaming response, once the headers of its request have come: with the status and the headers of
|
|
388
|
+
# the prelude of an HTTP integration, or of the content type of the request
|
|
389
|
+
def stream(self, api: str | None, content_type: str) -> None:
|
|
390
|
+
self.api = api
|
|
391
|
+
if content_type.split(';')[0].strip().lower() == INTEGRATION:
|
|
392
|
+
self.prelude = bytearray()
|
|
393
|
+
else:
|
|
394
|
+
self._start(200, [('Content-Type', content_type or 'application/octet-stream')])
|
|
395
|
+
|
|
396
|
+
# A chunk of a streaming response, after its prelude
|
|
397
|
+
def stream_chunk(self, chunk: bytes) -> None:
|
|
398
|
+
if self.done:
|
|
399
|
+
return
|
|
400
|
+
if (prelude := self.prelude) is not None:
|
|
401
|
+
prelude += chunk
|
|
402
|
+
if (end := prelude.find(DELIMITER)) < 0:
|
|
403
|
+
if len(prelude) > MAX_PRELUDE:
|
|
404
|
+
self.fail('Lambda function returned no prelude in the first 16 KB of its streaming response')
|
|
405
|
+
return
|
|
406
|
+
self.prelude = None
|
|
407
|
+
try:
|
|
408
|
+
value = json.loads(bytes(prelude[:end]))
|
|
409
|
+
code, headers = _status(cast(dict[str, Any], value) if isinstance(value, dict) else {})
|
|
410
|
+
except (AttributeError, KeyError, TypeError, ValueError) as e:
|
|
411
|
+
return self.fail(f'Lambda function returned an invalid prelude: {e}')
|
|
412
|
+
self._start(code, headers)
|
|
413
|
+
chunk = bytes(prelude[end + len(DELIMITER) :])
|
|
414
|
+
if not len(chunk):
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
# The next chunks of the runtime client once the chunk has been written to the client
|
|
418
|
+
connection = self.api
|
|
419
|
+
|
|
420
|
+
def drained(*args: Any) -> None:
|
|
421
|
+
self.pending -= 1
|
|
422
|
+
if self.closing and not self.pending:
|
|
423
|
+
return self._close()
|
|
424
|
+
if connection is not None and (stream := Pyjo.IOLoop.stream(connection)) is not None:
|
|
425
|
+
stream.start()
|
|
426
|
+
|
|
427
|
+
if connection is not None and (stream := Pyjo.IOLoop.stream(connection)) is not None:
|
|
428
|
+
stream.stop()
|
|
429
|
+
self._begin()
|
|
430
|
+
self.pending += 1
|
|
431
|
+
content = self.tx.res.content
|
|
432
|
+
(content.write_chunk if self.chunked else content.write)(chunk, drained)
|
|
433
|
+
self._rendered()
|
|
434
|
+
|
|
435
|
+
# The end of a streaming response, with the error of its trailers
|
|
436
|
+
def stream_end(self, error: str | None, body: str | None) -> None:
|
|
437
|
+
if error is not None:
|
|
438
|
+
message = _message(_json(base64.b64decode(body or ''))) if body else error
|
|
439
|
+
return self.fail(f'Lambda function error: {message}')
|
|
440
|
+
if self.done:
|
|
441
|
+
return
|
|
442
|
+
if self.prelude is not None:
|
|
443
|
+
return self.fail('Lambda function returned no prelude in its streaming response')
|
|
444
|
+
self.done = True
|
|
445
|
+
self._waited()
|
|
446
|
+
self._begin()
|
|
447
|
+
content = self.tx.res.content
|
|
448
|
+
(content.write_chunk if self.chunked else content.write)(b'')
|
|
449
|
+
self._rendered()
|
|
450
|
+
|
|
451
|
+
# The status and the headers of a streaming response, with its first chunk
|
|
452
|
+
def _begin(self) -> None:
|
|
453
|
+
if not self.started and (head := self.head) is not None:
|
|
454
|
+
self.started = True
|
|
455
|
+
self._headers(*head)
|
|
456
|
+
|
|
457
|
+
# The connection of the client, once what has been written to it is
|
|
458
|
+
def _close(self) -> None:
|
|
459
|
+
if (connection := self.tx.connection) is not None and (stream := Pyjo.IOLoop.stream(connection)) is not None:
|
|
460
|
+
stream.close_gracefully()
|
|
461
|
+
|
|
462
|
+
# The client is gone, or has its response
|
|
463
|
+
def _finish(self, *args: Any) -> None:
|
|
464
|
+
if self.done:
|
|
465
|
+
return
|
|
466
|
+
self.done = True
|
|
467
|
+
if (environment := self.environment) is not None:
|
|
468
|
+
environment.cancel(self)
|
|
469
|
+
|
|
470
|
+
def _headers(self, code: int, headers: list[tuple[str, str]]) -> None:
|
|
471
|
+
res = self.tx.res
|
|
472
|
+
res.code = code
|
|
473
|
+
for name, value in headers:
|
|
474
|
+
if not wsgiref.util.is_hop_by_hop(name) and name.lower() != 'content-length':
|
|
475
|
+
res.headers.add(name, value)
|
|
476
|
+
|
|
477
|
+
def _rendered(self) -> None:
|
|
478
|
+
if (c := self.c) is not None:
|
|
479
|
+
c.rendered()
|
|
480
|
+
else:
|
|
481
|
+
self.tx.resume()
|
|
482
|
+
|
|
483
|
+
# The status and the headers of a streaming response, which go with its first chunk, as its body is chunked, or ends
|
|
484
|
+
# with the connection of HTTP/1.0
|
|
485
|
+
def _start(self, code: int, headers: list[tuple[str, str]]) -> None:
|
|
486
|
+
self.head = (code, headers)
|
|
487
|
+
self.chunked = self.tx.req.version == '1.1'
|
|
488
|
+
if self.tx.is_empty:
|
|
489
|
+
self.done = True
|
|
490
|
+
self._waited()
|
|
491
|
+
self._begin()
|
|
492
|
+
self.tx.res.content.write(b'')
|
|
493
|
+
self._rendered()
|
|
494
|
+
|
|
495
|
+
# The inactivity timeout of the connection of the client again
|
|
496
|
+
def _waited(self) -> None:
|
|
497
|
+
if (waiting := self.waiting) is not None:
|
|
498
|
+
self.waiting = None
|
|
499
|
+
connection, timeout = waiting
|
|
500
|
+
if (stream := Pyjo.IOLoop.stream(connection)) is not None:
|
|
501
|
+
stream.timeout = timeout
|
|
502
|
+
|
|
503
|
+
def _write(self, code: int, headers: list[tuple[str, str]], body: bytes) -> None:
|
|
504
|
+
self.done = True
|
|
505
|
+
self._waited()
|
|
506
|
+
self._headers(code, headers)
|
|
507
|
+
self.tx.res.body = body
|
|
508
|
+
self._rendered()
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# An execution environment of the function: its process, and the Runtime API that it gets its invocations from, one
|
|
512
|
+
# after another
|
|
513
|
+
class _Environment:
|
|
514
|
+
def __init__(self, app: Pyjo_App_Lambda) -> None:
|
|
515
|
+
self.app = app
|
|
516
|
+
self.current: _Invocation | None = None
|
|
517
|
+
self.ended = False
|
|
518
|
+
self.failed = False
|
|
519
|
+
self.next: Any = None
|
|
520
|
+
self.started = False
|
|
521
|
+
self.queue: deque[_Invocation] = deque()
|
|
522
|
+
self.timer: str | None = None
|
|
523
|
+
|
|
524
|
+
api = _RuntimeAPI()
|
|
525
|
+
api.environment = weakref.ref(self)
|
|
526
|
+
api.log = app.log
|
|
527
|
+
self.daemon = Pyjo.Server.Daemon.new(
|
|
528
|
+
app=api, listen=['http://127.0.0.1'], silent=True, inactivity_timeout=0
|
|
529
|
+
).start()
|
|
530
|
+
|
|
531
|
+
# The variables of Lambda, of the runtime client of Pyjo.Server.Lambda without a command, and of the attribute
|
|
532
|
+
root: str = os.path.abspath(os.fspath(app.task_root)) if app.task_root is not None else os.getcwd()
|
|
533
|
+
name = app.function_name
|
|
534
|
+
env: dict[str, str] = {
|
|
535
|
+
**os.environ,
|
|
536
|
+
'AWS_LAMBDA_RUNTIME_API': f'127.0.0.1:{self.daemon.ports()[0]}',
|
|
537
|
+
'AWS_LAMBDA_FUNCTION_NAME': name,
|
|
538
|
+
'AWS_LAMBDA_FUNCTION_VERSION': '$LATEST',
|
|
539
|
+
'AWS_LAMBDA_FUNCTION_MEMORY_SIZE': str(app.memory_size),
|
|
540
|
+
'AWS_LAMBDA_LOG_GROUP_NAME': f'/aws/lambda/{name}',
|
|
541
|
+
'AWS_LAMBDA_LOG_STREAM_NAME': f'{time.strftime("%Y/%m/%d", time.gmtime())}/[$LATEST]{uuid.uuid4().hex}',
|
|
542
|
+
'LAMBDA_TASK_ROOT': root,
|
|
543
|
+
'_HANDLER': app.function or '',
|
|
544
|
+
'PYTHONUNBUFFERED': '1',
|
|
545
|
+
}
|
|
546
|
+
env.setdefault('AWS_REGION', REGION)
|
|
547
|
+
command = app.command
|
|
548
|
+
if command is None:
|
|
549
|
+
command = [sys.executable, '-m', 'Pyjo.Server.Lambda']
|
|
550
|
+
checkout = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(Pyjo.App.__file__))))
|
|
551
|
+
env['PYTHONPATH'] = os.pathsep.join([checkout, *filter(None, [env.get('PYTHONPATH')])])
|
|
552
|
+
env.update(app.env)
|
|
553
|
+
|
|
554
|
+
ref = weakref.ref(self)
|
|
555
|
+
self.child = child = Pyjo.IOLoop.spawn(command, cwd=root, detached=True, env=env)
|
|
556
|
+
for stream, log in ((child.stdout, app.log.info), (child.stderr, app.log.warn)):
|
|
557
|
+
if stream is not None:
|
|
558
|
+
lines = _Lines(log)
|
|
559
|
+
stream.on('read', lines.read)
|
|
560
|
+
stream.on('close', lines.closed)
|
|
561
|
+
|
|
562
|
+
def error(child: Any, err: Any) -> None:
|
|
563
|
+
if (environment := ref()) is not None:
|
|
564
|
+
environment.end(f'Lambda function failed: {err}', True)
|
|
565
|
+
|
|
566
|
+
def close(child: Any, code: int | None, signal: int | None) -> None:
|
|
567
|
+
if (environment := ref()) is not None:
|
|
568
|
+
# A process that ends before it asks for an invocation fails the ones that wait for it too
|
|
569
|
+
status = f'signal {signal}' if signal else f'status {code}'
|
|
570
|
+
failed = environment.failed or not environment.started
|
|
571
|
+
environment.end(f'Lambda function ended with {status}', failed)
|
|
572
|
+
|
|
573
|
+
child.on('error', error)
|
|
574
|
+
child.on('close', close)
|
|
575
|
+
|
|
576
|
+
def cancel(self, invocation: _Invocation) -> None:
|
|
577
|
+
if invocation in self.queue:
|
|
578
|
+
self.queue.remove(invocation)
|
|
579
|
+
|
|
580
|
+
def close(self) -> None:
|
|
581
|
+
self.end('Lambda function closed', True)
|
|
582
|
+
|
|
583
|
+
# The invocation for the runtime client that waits for it
|
|
584
|
+
def dispatch(self) -> None:
|
|
585
|
+
if self.current is not None or not self.queue or (tx := self.next) is None:
|
|
586
|
+
return
|
|
587
|
+
self.next = None
|
|
588
|
+
self.current = invocation = self.queue.popleft()
|
|
589
|
+
app = self.app
|
|
590
|
+
deadline = int((time.time() + app.timeout) * 1000)
|
|
591
|
+
res = tx.res
|
|
592
|
+
res.code = 200
|
|
593
|
+
headers = res.headers
|
|
594
|
+
headers.content_type = 'application/json'
|
|
595
|
+
headers.header('Lambda-Runtime-Aws-Request-Id', invocation.id)
|
|
596
|
+
headers.header('Lambda-Runtime-Deadline-Ms', str(deadline))
|
|
597
|
+
headers.header('Lambda-Runtime-Invoked-Function-Arn', _arn(app))
|
|
598
|
+
headers.header('Lambda-Runtime-Trace-Id', f'Root=1-{int(time.time()):08x}-{uuid.uuid4().hex[:24]}')
|
|
599
|
+
res.body = json.dumps(invocation.event).encode()
|
|
600
|
+
tx.resume()
|
|
601
|
+
|
|
602
|
+
ref = weakref.ref(self)
|
|
603
|
+
|
|
604
|
+
def timeout(loop: Any) -> None:
|
|
605
|
+
if (environment := ref()) is not None:
|
|
606
|
+
environment.timer = None
|
|
607
|
+
environment.timed_out()
|
|
608
|
+
|
|
609
|
+
self.timer = Pyjo.IOLoop.timer(app.timeout, timeout)
|
|
610
|
+
|
|
611
|
+
# The end of the process, of a failure or its own, which fails the invocation that it had, and the ones that wait
|
|
612
|
+
# for it when the function failed, where they go to a new process otherwise
|
|
613
|
+
def end(self, message: str, failed: bool) -> None:
|
|
614
|
+
first = not self.ended
|
|
615
|
+
self.ended = True
|
|
616
|
+
self._stop()
|
|
617
|
+
if (invocation := self.current) is not None:
|
|
618
|
+
self.current = None
|
|
619
|
+
invocation.fail(message)
|
|
620
|
+
queue, self.queue = list(self.queue), deque()
|
|
621
|
+
if first:
|
|
622
|
+
self.child.kill(signal.SIGKILL)
|
|
623
|
+
self.daemon.close()
|
|
624
|
+
if failed:
|
|
625
|
+
for invocation in queue:
|
|
626
|
+
invocation.fail(message)
|
|
627
|
+
elif queue:
|
|
628
|
+
environment = self.app._environment() # pyright: ignore[reportPrivateUsage]
|
|
629
|
+
for invocation in queue:
|
|
630
|
+
environment.invoke(invocation)
|
|
631
|
+
|
|
632
|
+
def error(self, id: str, error: dict[str, Any]) -> None:
|
|
633
|
+
if (invocation := self._take(id)) is not None:
|
|
634
|
+
invocation.fail(f'Lambda function error: {_message(error)}')
|
|
635
|
+
self.dispatch()
|
|
636
|
+
|
|
637
|
+
def init_error(self, error: dict[str, Any]) -> None:
|
|
638
|
+
self.failed = True
|
|
639
|
+
self.app.log.error(f'Lambda function initialization error: {_message(error)}')
|
|
640
|
+
|
|
641
|
+
def invoke(self, invocation: _Invocation) -> None:
|
|
642
|
+
invocation.environment = self
|
|
643
|
+
self.queue.append(invocation)
|
|
644
|
+
self.dispatch()
|
|
645
|
+
|
|
646
|
+
# The child of a fork leaves the process to its parent, and closes its copy of the server
|
|
647
|
+
def leave(self) -> None:
|
|
648
|
+
self.ended = True
|
|
649
|
+
self._stop()
|
|
650
|
+
self.daemon.close()
|
|
651
|
+
|
|
652
|
+
def response(self, id: str, body: bytes) -> None:
|
|
653
|
+
if (invocation := self._take(id)) is not None:
|
|
654
|
+
invocation.respond(body)
|
|
655
|
+
self.dispatch()
|
|
656
|
+
|
|
657
|
+
# The start of the streaming response of the current invocation, which stays current until its end, as its timeout
|
|
658
|
+
# goes on
|
|
659
|
+
def streaming(self, id: str, api: str | None, content_type: str) -> _Invocation | None:
|
|
660
|
+
if (invocation := self.current) is None or invocation.id != id:
|
|
661
|
+
return None
|
|
662
|
+
invocation.stream(api, content_type)
|
|
663
|
+
return invocation
|
|
664
|
+
|
|
665
|
+
def streamed(self, id: str, error: str | None, body: str | None) -> None:
|
|
666
|
+
if (invocation := self._take(id)) is not None:
|
|
667
|
+
invocation.stream_end(error, body)
|
|
668
|
+
self.dispatch()
|
|
669
|
+
|
|
670
|
+
def timed_out(self) -> None:
|
|
671
|
+
app = self.app
|
|
672
|
+
self.end(f'Lambda function timed out after {app.timeout} seconds', False)
|
|
673
|
+
|
|
674
|
+
def wait(self, tx: Any) -> None:
|
|
675
|
+
self.next = tx
|
|
676
|
+
self.started = True
|
|
677
|
+
ref = weakref.ref(self)
|
|
678
|
+
|
|
679
|
+
def finish(tx: Any) -> None:
|
|
680
|
+
if (environment := ref()) is not None and environment.next is tx:
|
|
681
|
+
environment.next = None
|
|
682
|
+
|
|
683
|
+
tx.on('finish', finish)
|
|
684
|
+
self.dispatch()
|
|
685
|
+
|
|
686
|
+
def _stop(self) -> None:
|
|
687
|
+
if (timer := self.timer) is not None:
|
|
688
|
+
self.timer = None
|
|
689
|
+
Pyjo.IOLoop.remove(timer)
|
|
690
|
+
|
|
691
|
+
def _take(self, id: str) -> _Invocation | None:
|
|
692
|
+
if (invocation := self.current) is None or invocation.id != id:
|
|
693
|
+
return None
|
|
694
|
+
self._stop()
|
|
695
|
+
self.current = None
|
|
696
|
+
return invocation
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
# The lines of an output of the process, which are logged as they come
|
|
700
|
+
class _Lines:
|
|
701
|
+
def __init__(self, log: Any) -> None:
|
|
702
|
+
self.log = log
|
|
703
|
+
self.buffer = bytearray()
|
|
704
|
+
|
|
705
|
+
def closed(self, stream: Any) -> None:
|
|
706
|
+
if len(self.buffer):
|
|
707
|
+
self.line(bytes(self.buffer))
|
|
708
|
+
self.buffer.clear()
|
|
709
|
+
|
|
710
|
+
def line(self, line: bytes) -> None:
|
|
711
|
+
self.log(f'Lambda function: {line.decode("utf-8", "replace").rstrip()}')
|
|
712
|
+
|
|
713
|
+
def read(self, stream: Any, chunk: bytes) -> None:
|
|
714
|
+
self.buffer += chunk
|
|
715
|
+
while (end := self.buffer.find(b'\n')) >= 0:
|
|
716
|
+
self.line(bytes(self.buffer[:end]))
|
|
717
|
+
del self.buffer[: end + 1]
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
# The Runtime API of an execution environment, for its process
|
|
721
|
+
class _RuntimeAPI(Pyjo.App.object):
|
|
722
|
+
environment: 'weakref.ref[_Environment] | None' = None
|
|
723
|
+
|
|
724
|
+
# A streaming response goes to the invocation as it comes, once the headers of its request are there, and its body is
|
|
725
|
+
# not kept
|
|
726
|
+
def build_tx(self) -> Pyjo.Transaction.HTTP.object:
|
|
727
|
+
tx = super().build_tx()
|
|
728
|
+
req = weakref.ref(tx.req)
|
|
729
|
+
ref = weakref.ref(tx)
|
|
730
|
+
environment = self.environment
|
|
731
|
+
|
|
732
|
+
def body(content: Any) -> None:
|
|
733
|
+
if (r := req()) is None or (t := ref()) is None or not _is_streaming(r):
|
|
734
|
+
return
|
|
735
|
+
m = re.fullmatch(rf'{PREFIX}/invocation/([^/]+)/response', r.url.path.to_str())
|
|
736
|
+
env = environment() if environment is not None else None
|
|
737
|
+
if m is None or env is None or env.ended:
|
|
738
|
+
return
|
|
739
|
+
invocation = env.streaming(m.group(1), t.connection, r.headers.content_type or '')
|
|
740
|
+
if invocation is None:
|
|
741
|
+
return
|
|
742
|
+
|
|
743
|
+
def read(content: Any, chunk: bytes) -> None:
|
|
744
|
+
invocation.stream_chunk(bytes(chunk))
|
|
745
|
+
|
|
746
|
+
content.unsubscribe('read').on('read', read)
|
|
747
|
+
|
|
748
|
+
tx.req.content.on('body', body)
|
|
749
|
+
return tx
|
|
750
|
+
|
|
751
|
+
def handler(self, tx: Any) -> None:
|
|
752
|
+
environment = self.environment() if self.environment is not None else None
|
|
753
|
+
req = tx.req
|
|
754
|
+
method, path = req.method, req.url.path.to_str()
|
|
755
|
+
if environment is not None and not environment.ended:
|
|
756
|
+
if method == 'GET' and path == f'{PREFIX}/invocation/next':
|
|
757
|
+
return environment.wait(tx)
|
|
758
|
+
if method == 'POST' and (m := re.fullmatch(rf'{PREFIX}/invocation/([^/]+)/(response|error)', path)):
|
|
759
|
+
id, kind = m.group(1), m.group(2)
|
|
760
|
+
_reply(tx, 202)
|
|
761
|
+
if kind == 'response' and _is_streaming(req):
|
|
762
|
+
headers = req.headers
|
|
763
|
+
return environment.streamed(
|
|
764
|
+
id,
|
|
765
|
+
headers.header('Lambda-Runtime-Function-Error-Type'),
|
|
766
|
+
headers.header('Lambda-Runtime-Function-Error-Body'),
|
|
767
|
+
)
|
|
768
|
+
if kind == 'response':
|
|
769
|
+
return environment.response(id, req.body)
|
|
770
|
+
return environment.error(id, _json(req.body))
|
|
771
|
+
if method == 'POST' and path == f'{PREFIX}/init/error':
|
|
772
|
+
_reply(tx, 202)
|
|
773
|
+
return environment.init_error(_json(req.body))
|
|
774
|
+
_reply(tx, 404)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
# The ARN of the function
|
|
778
|
+
def _arn(app: Pyjo_App_Lambda) -> str:
|
|
779
|
+
return f'arn:aws:lambda:{REGION}:{ACCOUNT}:function:{app.function_name}'
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
# The body of the request, as it is in text, or in Base64
|
|
783
|
+
def _body(req: Any) -> tuple[str | None, bool]:
|
|
784
|
+
body: bytes = req.body
|
|
785
|
+
if not len(body):
|
|
786
|
+
return None, False
|
|
787
|
+
if _RE_TEXT.match(req.headers.content_type or '') and _is_utf8(body):
|
|
788
|
+
return body.decode(), False
|
|
789
|
+
return base64.b64encode(body).decode('ascii'), True
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
# The event of the request in the payload format of the application
|
|
793
|
+
def _event(app: Pyjo_App_Lambda, tx: Any, c: Any, id: str) -> dict[str, Any]:
|
|
794
|
+
req = tx.req
|
|
795
|
+
url = req.url
|
|
796
|
+
script, path = Pyjo.App._mount(tx, c) # pyright: ignore[reportPrivateUsage]
|
|
797
|
+
script = script.rstrip('/')
|
|
798
|
+
full = script + path or '/'
|
|
799
|
+
path = path or '/'
|
|
800
|
+
stage = script.strip('/') or '$default'
|
|
801
|
+
pairs = [(name, value) for name in req.headers.names() for value in req.headers.every_header(name)]
|
|
802
|
+
body, encoded = _body(req)
|
|
803
|
+
method = req.method
|
|
804
|
+
address = tx.remote_address or '127.0.0.1'
|
|
805
|
+
agent = req.headers.user_agent or ''
|
|
806
|
+
host = req.headers.host or url.base.host or 'localhost'
|
|
807
|
+
now = time.time()
|
|
808
|
+
|
|
809
|
+
# The format 2.0 of HTTP APIs and Function URLs, with the path of the mount as its stage
|
|
810
|
+
if app.payload == '2.0':
|
|
811
|
+
headers: dict[str, str] = {}
|
|
812
|
+
cookies: list[str] = []
|
|
813
|
+
for name, value in pairs:
|
|
814
|
+
if (name := name.lower()) == 'cookie':
|
|
815
|
+
cookies += [cookie for cookie in value.split('; ') if cookie]
|
|
816
|
+
else:
|
|
817
|
+
headers[name] = f'{headers[name]},{value}' if name in headers else value
|
|
818
|
+
query: dict[str, str] = {}
|
|
819
|
+
for name, value in url.query.pairs:
|
|
820
|
+
query[name] = f'{query[name]},{value}' if name in query else value
|
|
821
|
+
event: dict[str, Any] = {
|
|
822
|
+
'version': '2.0',
|
|
823
|
+
'routeKey': '$default',
|
|
824
|
+
'rawPath': full,
|
|
825
|
+
'rawQueryString': url.query.to_str(),
|
|
826
|
+
'headers': headers,
|
|
827
|
+
'requestContext': {
|
|
828
|
+
'accountId': ACCOUNT,
|
|
829
|
+
'apiId': API,
|
|
830
|
+
'domainName': host,
|
|
831
|
+
'domainPrefix': host.split('.')[0],
|
|
832
|
+
'http': {
|
|
833
|
+
'method': method,
|
|
834
|
+
'path': full,
|
|
835
|
+
'protocol': f'HTTP/{req.version}',
|
|
836
|
+
'sourceIp': address,
|
|
837
|
+
'userAgent': agent,
|
|
838
|
+
},
|
|
839
|
+
'requestId': id,
|
|
840
|
+
'routeKey': '$default',
|
|
841
|
+
'stage': stage,
|
|
842
|
+
'time': time.strftime('%d/%b/%Y:%H:%M:%S +0000', time.gmtime(now)),
|
|
843
|
+
'timeEpoch': int(now * 1000),
|
|
844
|
+
},
|
|
845
|
+
'isBase64Encoded': encoded,
|
|
846
|
+
}
|
|
847
|
+
if cookies:
|
|
848
|
+
event['cookies'] = cookies
|
|
849
|
+
if query:
|
|
850
|
+
event['queryStringParameters'] = query
|
|
851
|
+
if body is not None:
|
|
852
|
+
event['body'] = body
|
|
853
|
+
return event
|
|
854
|
+
|
|
855
|
+
# The format 1.0 of REST APIs, with the path of the mount as its stage, in front of the path of the context
|
|
856
|
+
if app.payload == '1.0':
|
|
857
|
+
single: dict[str, str] = {}
|
|
858
|
+
multi: dict[str, list[str]] = {}
|
|
859
|
+
for name, value in pairs:
|
|
860
|
+
single[name] = value
|
|
861
|
+
multi.setdefault(name, []).append(value)
|
|
862
|
+
params: dict[str, str] = {}
|
|
863
|
+
multi_params: dict[str, list[str]] = {}
|
|
864
|
+
for name, value in url.query.pairs:
|
|
865
|
+
params[name] = value
|
|
866
|
+
multi_params.setdefault(name, []).append(value)
|
|
867
|
+
return {
|
|
868
|
+
'version': '1.0',
|
|
869
|
+
'resource': '/{proxy+}',
|
|
870
|
+
'path': path,
|
|
871
|
+
'httpMethod': method,
|
|
872
|
+
'headers': single,
|
|
873
|
+
'multiValueHeaders': multi,
|
|
874
|
+
'queryStringParameters': params or None,
|
|
875
|
+
'multiValueQueryStringParameters': multi_params or None,
|
|
876
|
+
'pathParameters': {'proxy': path.lstrip('/')},
|
|
877
|
+
'stageVariables': None,
|
|
878
|
+
'requestContext': {
|
|
879
|
+
'accountId': ACCOUNT,
|
|
880
|
+
'apiId': API,
|
|
881
|
+
'domainName': host,
|
|
882
|
+
'httpMethod': method,
|
|
883
|
+
'identity': {'sourceIp': address, 'userAgent': agent},
|
|
884
|
+
'path': full,
|
|
885
|
+
'protocol': f'HTTP/{req.version}',
|
|
886
|
+
'requestId': id,
|
|
887
|
+
'requestTimeEpoch': int(now * 1000),
|
|
888
|
+
'resourcePath': '/{proxy+}',
|
|
889
|
+
'stage': stage,
|
|
890
|
+
},
|
|
891
|
+
'body': body,
|
|
892
|
+
'isBase64Encoded': encoded,
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
# The events of an Application Load Balancer, with the whole path and the parameters of the query as they were
|
|
896
|
+
# sent, and the headers in lower case, of multiple values when the target group has them
|
|
897
|
+
raw = [tuple(part.split('=', 1)) if '=' in part else (part, '') for part in url.query.to_str().split('&') if part]
|
|
898
|
+
event = {
|
|
899
|
+
'requestContext': {
|
|
900
|
+
'elb': {
|
|
901
|
+
'targetGroupArn': f'arn:aws:elasticloadbalancing:{REGION}:{ACCOUNT}:targetgroup/{API}/0123456789abcdef'
|
|
902
|
+
}
|
|
903
|
+
},
|
|
904
|
+
'httpMethod': method,
|
|
905
|
+
'path': full,
|
|
906
|
+
'body': body or '',
|
|
907
|
+
'isBase64Encoded': encoded,
|
|
908
|
+
}
|
|
909
|
+
if app.multi_value_headers:
|
|
910
|
+
multi_headers: dict[str, list[str]] = {}
|
|
911
|
+
for name, value in pairs:
|
|
912
|
+
multi_headers.setdefault(name.lower(), []).append(value)
|
|
913
|
+
multi_query: dict[str, list[str]] = {}
|
|
914
|
+
for name, value in raw:
|
|
915
|
+
multi_query.setdefault(name, []).append(value)
|
|
916
|
+
event['multiValueHeaders'] = multi_headers
|
|
917
|
+
event['multiValueQueryStringParameters'] = multi_query
|
|
918
|
+
else:
|
|
919
|
+
event['headers'] = {name.lower(): value for name, value in pairs}
|
|
920
|
+
event['queryStringParameters'] = dict(raw)
|
|
921
|
+
return event
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _is_streaming(req: Any) -> bool:
|
|
925
|
+
return req.method == 'POST' and req.headers.header('Lambda-Runtime-Function-Response-Mode') == 'streaming'
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
# The status and the headers of a response, or of the prelude of a streaming one, where the headers of multiple values
|
|
929
|
+
# take the place of the ones of the same name of one value
|
|
930
|
+
def _status(response: dict[str, Any]) -> tuple[int, list[tuple[str, str]]]:
|
|
931
|
+
code = int(response.get('statusCode', 200))
|
|
932
|
+
multi = cast(dict[str, list[Any]], response.get('multiValueHeaders') or {})
|
|
933
|
+
single = cast(dict[str, Any], response.get('headers') or {})
|
|
934
|
+
names = {str(name).lower() for name in multi}
|
|
935
|
+
headers = [(str(name), str(item)) for name, item in single.items() if str(name).lower() not in names]
|
|
936
|
+
headers += [(str(name), str(item)) for name, items in multi.items() for item in items]
|
|
937
|
+
headers += [('Set-Cookie', str(cookie)) for cookie in cast(list[Any], response.get('cookies') or [])]
|
|
938
|
+
return code, headers
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _json(body: bytes) -> dict[str, Any]:
|
|
942
|
+
try:
|
|
943
|
+
value = json.loads(body or b'{}')
|
|
944
|
+
except ValueError:
|
|
945
|
+
return {}
|
|
946
|
+
return cast(dict[str, Any], value) if isinstance(value, dict) else {}
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
# The message of an error of the function, with its type
|
|
950
|
+
def _message(error: dict[str, Any]) -> str:
|
|
951
|
+
kind, message = error.get('errorType'), error.get('errorMessage')
|
|
952
|
+
return f'{kind}: {message}' if kind else str(message)
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _reply(tx: Any, code: int) -> None:
|
|
956
|
+
res = tx.res
|
|
957
|
+
res.code = code
|
|
958
|
+
res.headers.content_type = 'application/json'
|
|
959
|
+
res.body = b'{"status":"OK"}' if code < 400 else b'{"errorMessage":"Not Found"}'
|
|
960
|
+
tx.resume()
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
# The applications whose execution environments the child of a fork leaves, at the fork. The handler is registered with the
|
|
964
|
+
# first environment, after the one of the reactor, which os.register_at_fork calls first, so that the reactor has moved
|
|
965
|
+
# to a loop of its own, as a loop of epoll shares its state with the parent
|
|
966
|
+
_apps: 'weakref.WeakSet[Pyjo_App_Lambda]' = weakref.WeakSet()
|
|
967
|
+
_registered = False
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _register() -> None:
|
|
971
|
+
global _registered
|
|
972
|
+
if not _registered and hasattr(os, 'register_at_fork'):
|
|
973
|
+
_registered = True
|
|
974
|
+
os.register_at_fork(after_in_child=_after_fork_in_child)
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def _after_fork_in_child() -> None:
|
|
978
|
+
for app in list(_apps):
|
|
979
|
+
try:
|
|
980
|
+
app._leave() # pyright: ignore[reportPrivateUsage]
|
|
981
|
+
except Exception:
|
|
982
|
+
pass
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
new = Pyjo_App_Lambda.new
|
|
986
|
+
object = Pyjo_App_Lambda
|
|
987
|
+
Lambda = Pyjo_App_Lambda
|