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/ASGI.py
ADDED
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pyjo.App.ASGI - ASGI application
|
|
3
|
+
================================
|
|
4
|
+
::
|
|
5
|
+
|
|
6
|
+
from Pyjo.App.ASGI import ASGI
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def hello(scope, receive, send):
|
|
10
|
+
await send({'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain')]})
|
|
11
|
+
await send({'type': 'http.response.body', 'body': b'Hello World!'})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
app = ASGI(app=hello)
|
|
15
|
+
|
|
16
|
+
# Or import the module, as in Google Python Style Guide
|
|
17
|
+
|
|
18
|
+
import Pyjo.App.ASGI
|
|
19
|
+
|
|
20
|
+
app = Pyjo.App.ASGI.new(app=hello)
|
|
21
|
+
|
|
22
|
+
# Mount it in an application of Pyjoyment
|
|
23
|
+
import Pyjoyment
|
|
24
|
+
|
|
25
|
+
pyjoyment = Pyjoyment.new()
|
|
26
|
+
pyjoyment.plugin('Mount', {'/hello': app})
|
|
27
|
+
|
|
28
|
+
:mod:`Pyjo.App.ASGI` is an application of :mod:`Pyjo` that runs an ``ASGI``
|
|
29
|
+
application, as https://asgi.readthedocs.io describes it, so that a server of
|
|
30
|
+
:mod:`Pyjo` serves it, a route of :mod:`Pyjoyment` embeds it, as
|
|
31
|
+
:mod:`Pyjoyment.Plugin.Mount` does, and :mod:`Pyjo.Test.Pyjo` tests it.
|
|
32
|
+
|
|
33
|
+
The part of the path that a route embeds the application under is
|
|
34
|
+
``root_path``, after the base path of the request, and ``path`` is the whole
|
|
35
|
+
path, with ``root_path`` in front of it. A route that embeds the application
|
|
36
|
+
at a part of the path that does not end with a slash, as ``/php`` of
|
|
37
|
+
``/phpx``, which ``root_path`` cannot be, does not find it. The body of the request is the
|
|
38
|
+
message ``http.request`` of ``receive``, in chunks with ``more_body``, and
|
|
39
|
+
the next ``receive`` waits for the end of the connection, the message
|
|
40
|
+
``http.disconnect``.
|
|
41
|
+
|
|
42
|
+
The application runs as a coroutine of the reactor, as
|
|
43
|
+
:meth:`Pyjo.Reactor.Pyjo_Reactor.run_coroutine` runs it: with
|
|
44
|
+
:mod:`Pyjo.Reactor.Asyncio`, a task of the event loop of :mod:`asyncio`,
|
|
45
|
+
where it can await anything of the loop, and with another reactor, as
|
|
46
|
+
:mod:`Pyjo.Reactor.Poll`, a coroutine that awaits only ``receive`` and
|
|
47
|
+
``send``, so that an application that awaits anything else fails there. A
|
|
48
|
+
response that the application gives in one message ``http.response.body`` is
|
|
49
|
+
written with its ``Content-Length``. Another one is written as the application
|
|
50
|
+
sends it, with the chunked transfer encoding when the application does not
|
|
51
|
+
give its ``Content-Length``, and ``send`` returns once the chunk has been
|
|
52
|
+
written, so that a stream of server-sent events goes at the pace of the
|
|
53
|
+
client. Once the connection is gone, ``send`` does nothing.
|
|
54
|
+
|
|
55
|
+
An exception of the application before its response starts is a response
|
|
56
|
+
``500``, the exception page of :mod:`Pyjoyment` in a route that embeds it, an
|
|
57
|
+
exception after that is logged and closes the connection, as does an
|
|
58
|
+
application that returns before the end of its response, and an exception
|
|
59
|
+
after the end of the response is logged.
|
|
60
|
+
|
|
61
|
+
A WebSocket handshake is the connection ``websocket``, whose
|
|
62
|
+
``subprotocols`` are the ones that the client offers. The response of the
|
|
63
|
+
handshake waits for the message ``websocket.accept``, whose ``subprotocol``
|
|
64
|
+
and ``headers`` are those of the response ``101``, and a ``websocket.close``
|
|
65
|
+
before it rejects the handshake with the response ``403``, as does a response
|
|
66
|
+
of the application with the extension ``websocket.http.response``. The
|
|
67
|
+
messages of the WebSocket are ``websocket.receive``, and ``send`` of
|
|
68
|
+
``websocket.send`` returns once the message has been written. The end of the
|
|
69
|
+
WebSocket, also of a connection that is gone before the response of its
|
|
70
|
+
handshake, is the message ``websocket.disconnect`` with the status of the
|
|
71
|
+
close, after which ``send`` does nothing. An exception of the application, or
|
|
72
|
+
its return, before the response of the handshake is a response ``500``, and
|
|
73
|
+
after it is logged and closes the WebSocket with the status ``1011``, unless
|
|
74
|
+
the application has closed it.
|
|
75
|
+
|
|
76
|
+
The WebSockets are pinged, as by an ``ASGI`` server, with
|
|
77
|
+
:meth:`Pyjo.Transaction.WebSocket.Pyjo_Transaction_WebSocket.heartbeat` and
|
|
78
|
+
the intervals of :attr:`ping_interval` and :attr:`ping_timeout`, so that the
|
|
79
|
+
inactivity timeout of the server does not close a WebSocket without messages,
|
|
80
|
+
and a client that is gone is found.
|
|
81
|
+
|
|
82
|
+
The lifespan of the application, the connection ``lifespan``, starts with
|
|
83
|
+
the first request in each event loop, as of each process of
|
|
84
|
+
:mod:`Pyjo.Server.Prefork` and each thread of :mod:`Pyjo.Server.Prethread`,
|
|
85
|
+
or with :meth:`startup`, and the requests wait for its
|
|
86
|
+
``lifespan.startup.complete``, after which each gets a copy of its ``state``.
|
|
87
|
+
A ``lifespan.startup.failed`` is logged, and the requests get the response
|
|
88
|
+
``500``. An application that fails or returns before its startup has no
|
|
89
|
+
lifespan, as uvicorn takes it, and one that neither answers nor fails keeps
|
|
90
|
+
the requests waiting. The lifespan shuts down once the server has stopped
|
|
91
|
+
gracefully, with :meth:`server_stopped`, which
|
|
92
|
+
:mod:`Pyjoyment.Plugin.Mount` calls with the hook ``after_server_stop``,
|
|
93
|
+
with :meth:`close`, as at the end of a ``with`` block, and at the end of the
|
|
94
|
+
interpreter, where nothing has shut it down.
|
|
95
|
+
|
|
96
|
+
Attributes
|
|
97
|
+
----------
|
|
98
|
+
|
|
99
|
+
:mod:`Pyjo.App.ASGI` inherits all attributes from :mod:`Pyjo.App` and
|
|
100
|
+
implements the following new ones.
|
|
101
|
+
|
|
102
|
+
See also
|
|
103
|
+
--------
|
|
104
|
+
|
|
105
|
+
:mod:`Pyjoyment`, :mod:`Pyjoyment.Plugin.Mount`, :mod:`Pyjo.App.WSGI`,
|
|
106
|
+
:mod:`Pyjo.Server.ASGI`, https://mojolicious.org.
|
|
107
|
+
|
|
108
|
+
It has no counterpart in Mojolicious; it is the reverse of
|
|
109
|
+
:mod:`Pyjo.Server.ASGI`.
|
|
110
|
+
|
|
111
|
+
Classes
|
|
112
|
+
-------
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
import Pyjo.App
|
|
116
|
+
import Pyjo.Exception
|
|
117
|
+
import Pyjo.IOLoop
|
|
118
|
+
import Pyjo.Promise
|
|
119
|
+
import Pyjo.Transaction
|
|
120
|
+
|
|
121
|
+
from Pyjo.Server.ASGI import Application, Message, Scope
|
|
122
|
+
from Pyjo.Util import url_unescape
|
|
123
|
+
|
|
124
|
+
import atexit
|
|
125
|
+
import collections
|
|
126
|
+
import traceback
|
|
127
|
+
import weakref
|
|
128
|
+
import wsgiref.util
|
|
129
|
+
|
|
130
|
+
from typing import Any
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Pyjo_App_ASGI(Pyjo.App.object):
|
|
134
|
+
"""
|
|
135
|
+
:mod:`Pyjo.App.ASGI` inherits all methods from :mod:`Pyjo.App` and
|
|
136
|
+
implements the following new ones.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(self, attrs: dict[str, Any] | None = None, /, **kwargs: Any) -> None:
|
|
140
|
+
"""::
|
|
141
|
+
|
|
142
|
+
app = Pyjo.App.ASGI.new(app=application)
|
|
143
|
+
|
|
144
|
+
Construct a new :mod:`Pyjo.App.ASGI` object.
|
|
145
|
+
"""
|
|
146
|
+
self.app: Application | None = None
|
|
147
|
+
"""::
|
|
148
|
+
|
|
149
|
+
application = app.app
|
|
150
|
+
app.app = application
|
|
151
|
+
|
|
152
|
+
The ``ASGI`` application to run.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
self.ping_interval: float | None = 20
|
|
156
|
+
"""::
|
|
157
|
+
|
|
158
|
+
interval = app.ping_interval
|
|
159
|
+
app.ping_interval = 60
|
|
160
|
+
|
|
161
|
+
Interval in seconds of the pings of the WebSockets, defaults to
|
|
162
|
+
``20``, as of uvicorn. ``None`` or ``0`` sends no pings.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
self.ping_timeout: float | None = 20
|
|
166
|
+
"""::
|
|
167
|
+
|
|
168
|
+
timeout = app.ping_timeout
|
|
169
|
+
app.ping_timeout = 60
|
|
170
|
+
|
|
171
|
+
Timeout in seconds of the answer of a client to a ping of a WebSocket,
|
|
172
|
+
after which its connection is closed, defaults to ``20``, as of
|
|
173
|
+
uvicorn. ``None`` or ``0`` waits for no answer.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
# The lifespan of the application in each event loop, as each thread has one
|
|
177
|
+
self._lifespans: weakref.WeakKeyDictionary[Any, _Lifespan] = weakref.WeakKeyDictionary()
|
|
178
|
+
|
|
179
|
+
super().__init__(attrs, **kwargs)
|
|
180
|
+
|
|
181
|
+
def close(self) -> None:
|
|
182
|
+
"""::
|
|
183
|
+
|
|
184
|
+
app.close()
|
|
185
|
+
|
|
186
|
+
Shut the lifespan of the application down, and wait for it, unless
|
|
187
|
+
the event loop runs, as the end of a ``with`` block does, and close
|
|
188
|
+
the connections and the servers of :attr:`ua`.
|
|
189
|
+
"""
|
|
190
|
+
if (promise := self.shutdown()) is not None and not Pyjo.IOLoop.is_running():
|
|
191
|
+
promise.wait()
|
|
192
|
+
super().close()
|
|
193
|
+
|
|
194
|
+
def handler(self, tx: Any) -> None:
|
|
195
|
+
"""::
|
|
196
|
+
|
|
197
|
+
app.handler(Pyjo.Transaction.HTTP.new())
|
|
198
|
+
app.handler(Pyjoyment.Controller.new())
|
|
199
|
+
|
|
200
|
+
Run the application for the request of the transaction, or of the
|
|
201
|
+
controller of a route that embeds the application, and write its
|
|
202
|
+
response.
|
|
203
|
+
"""
|
|
204
|
+
if (application := self.app) is None:
|
|
205
|
+
raise Pyjo.Exception.new('No ASGI application')
|
|
206
|
+
c = None
|
|
207
|
+
if not isinstance(tx, Pyjo.Transaction.object):
|
|
208
|
+
c = tx
|
|
209
|
+
tx = c.tx
|
|
210
|
+
if not Pyjo.App._within(c): # pyright: ignore[reportPrivateUsage]
|
|
211
|
+
return c.helpers.reply.not_found()
|
|
212
|
+
c.render_later()
|
|
213
|
+
call = _WebSocket(self, application, tx, c) if tx.is_websocket else _Call(self, application, tx, c)
|
|
214
|
+
|
|
215
|
+
# The lifespan of the application starts with its first request, which waits for its startup
|
|
216
|
+
if (lifespan := self._lifespan()).ready:
|
|
217
|
+
return call.start(lifespan)
|
|
218
|
+
lifespan.started.then(lambda *args: call.start(lifespan))
|
|
219
|
+
|
|
220
|
+
def server_stopped(self, server: Any) -> Pyjo.Promise.object | None:
|
|
221
|
+
"""::
|
|
222
|
+
|
|
223
|
+
promise = app.server_stopped(Pyjo.Server.Daemon.new())
|
|
224
|
+
|
|
225
|
+
Shut the lifespan of the application down, as :meth:`shutdown` does,
|
|
226
|
+
once the server has stopped.
|
|
227
|
+
"""
|
|
228
|
+
return self.shutdown()
|
|
229
|
+
|
|
230
|
+
def shutdown(self) -> Pyjo.Promise.object | None:
|
|
231
|
+
"""::
|
|
232
|
+
|
|
233
|
+
promise = app.shutdown()
|
|
234
|
+
|
|
235
|
+
Shut the lifespan of the application in the event loop of the thread
|
|
236
|
+
down, with the message ``lifespan.shutdown``, and return a promise
|
|
237
|
+
that is fulfilled once the application has answered it, or ``None``
|
|
238
|
+
when the lifespan has not started.
|
|
239
|
+
"""
|
|
240
|
+
if (lifespan := self._lifespans.pop(Pyjo.IOLoop.object.singleton(), None)) is None:
|
|
241
|
+
return None
|
|
242
|
+
return lifespan.shutdown()
|
|
243
|
+
|
|
244
|
+
def startup(self) -> Pyjo.Promise.object:
|
|
245
|
+
"""::
|
|
246
|
+
|
|
247
|
+
promise = app.startup()
|
|
248
|
+
|
|
249
|
+
Start the lifespan of the application in the event loop of the
|
|
250
|
+
thread, with the message ``lifespan.startup``, unless it has started,
|
|
251
|
+
and return a promise that is fulfilled once the application has
|
|
252
|
+
answered it, as the first request does.
|
|
253
|
+
"""
|
|
254
|
+
return self._lifespan().started
|
|
255
|
+
|
|
256
|
+
def _lifespan(self) -> '_Lifespan':
|
|
257
|
+
loop = Pyjo.IOLoop.object.singleton()
|
|
258
|
+
if (lifespan := self._lifespans.get(loop)) is None:
|
|
259
|
+
if (application := self.app) is None:
|
|
260
|
+
raise Pyjo.Exception.new('No ASGI application')
|
|
261
|
+
lifespan = self._lifespans[loop] = _Lifespan(self, application)
|
|
262
|
+
lifespan.start()
|
|
263
|
+
return lifespan
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# A call of the application, whose messages are the request and the response of the transaction
|
|
267
|
+
class _Call:
|
|
268
|
+
def __init__(self, app: Pyjo_App_ASGI, application: Application, tx: Any, c: Any) -> None:
|
|
269
|
+
self.app = app
|
|
270
|
+
self.application = application
|
|
271
|
+
self.c = c
|
|
272
|
+
self.tx = tx
|
|
273
|
+
self.chunked = False
|
|
274
|
+
self.closed = False
|
|
275
|
+
self.ended = False
|
|
276
|
+
self.finished = Pyjo.Promise.new()
|
|
277
|
+
self.headers: list[tuple[str, str]] = []
|
|
278
|
+
self.offset = 0
|
|
279
|
+
self.read = False
|
|
280
|
+
self.started = False
|
|
281
|
+
self.status: int | None = None
|
|
282
|
+
self.written: Pyjo.Promise.object | None = None
|
|
283
|
+
|
|
284
|
+
def start(self, lifespan: '_Lifespan') -> None:
|
|
285
|
+
self.tx.on('finish', self._finish)
|
|
286
|
+
if lifespan.failed is not None:
|
|
287
|
+
return self._error(Pyjo.Exception.new(f'ASGI application failed to start: {lifespan.failed}'))
|
|
288
|
+
try:
|
|
289
|
+
coro = self.application(lifespan.scope(_scope(self.tx, self.c)), self._receive, self._send)
|
|
290
|
+
except Exception as e:
|
|
291
|
+
return self._error(e)
|
|
292
|
+
Pyjo.Promise.new(coro).then(self._returned, self._error)
|
|
293
|
+
|
|
294
|
+
# End of the response
|
|
295
|
+
def _end(self) -> None:
|
|
296
|
+
self.ended = True
|
|
297
|
+
content = self.tx.res.content
|
|
298
|
+
(content.write_chunk if self.chunked else content.write)(b'')
|
|
299
|
+
self._rendered()
|
|
300
|
+
|
|
301
|
+
def _error(self, e: Any) -> None:
|
|
302
|
+
message = _message(e)
|
|
303
|
+
|
|
304
|
+
# The response has started, so the connection is closed, as the response cannot change, unless it has ended, as
|
|
305
|
+
# Starlette raises the exception after its response 500
|
|
306
|
+
if self.started:
|
|
307
|
+
if self.ended or not self.closed:
|
|
308
|
+
self.app.log.error(f'ASGI application failed: {message}')
|
|
309
|
+
if not self.ended:
|
|
310
|
+
self._close()
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
# The exception page of Pyjoyment
|
|
314
|
+
self.started = self.ended = True
|
|
315
|
+
if (c := self.c) is not None:
|
|
316
|
+
c.helpers.reply.exception(e)
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
self.app.log.error(f'ASGI application failed: {message}')
|
|
320
|
+
res = self.tx.res
|
|
321
|
+
res.code = 500
|
|
322
|
+
res.headers.content_type = 'text/plain'
|
|
323
|
+
res.body = b'Internal Server Error'
|
|
324
|
+
self.tx.resume()
|
|
325
|
+
|
|
326
|
+
def _close(self) -> None:
|
|
327
|
+
if (connection := self.tx.connection) is not None and (stream := Pyjo.IOLoop.stream(connection)) is not None:
|
|
328
|
+
stream.close()
|
|
329
|
+
|
|
330
|
+
# The end of the transaction, also when the connection is gone before the end of the response, after which receive
|
|
331
|
+
# gives http.disconnect and send does nothing
|
|
332
|
+
def _finish(self, *args: Any) -> None:
|
|
333
|
+
self.closed = True
|
|
334
|
+
self.finished.resolve()
|
|
335
|
+
if (written := self.written) is not None:
|
|
336
|
+
self.written = None
|
|
337
|
+
written.resolve()
|
|
338
|
+
|
|
339
|
+
# The status and the headers of http.response.start, and the chunked transfer encoding for a body that comes in more
|
|
340
|
+
# than one message without its length
|
|
341
|
+
def _headers(self, whole: bool) -> None:
|
|
342
|
+
self.started = True
|
|
343
|
+
res = self.tx.res
|
|
344
|
+
res.code = self.status
|
|
345
|
+
headers = res.headers
|
|
346
|
+
for name, value in self.headers:
|
|
347
|
+
if not wsgiref.util.is_hop_by_hop(name):
|
|
348
|
+
headers.add(name, value)
|
|
349
|
+
self.chunked = not whole and headers.content_length is None and self.tx.req.version == '1.1'
|
|
350
|
+
|
|
351
|
+
async def _receive(self) -> Message:
|
|
352
|
+
# The body of the request, which has been read whole, in chunks
|
|
353
|
+
if not self.read:
|
|
354
|
+
content = self.tx.req.content
|
|
355
|
+
chunk = content.get_body_chunk(self.offset) or b''
|
|
356
|
+
self.offset += len(chunk)
|
|
357
|
+
more = len(chunk) > 0 and self.offset < content.body_size
|
|
358
|
+
self.read = not more
|
|
359
|
+
return {'type': 'http.request', 'body': bytes(chunk), 'more_body': more}
|
|
360
|
+
|
|
361
|
+
# The end of the connection
|
|
362
|
+
if not self.closed:
|
|
363
|
+
await self.finished
|
|
364
|
+
return {'type': 'http.disconnect'}
|
|
365
|
+
|
|
366
|
+
def _rendered(self) -> None:
|
|
367
|
+
if (c := self.c) is not None:
|
|
368
|
+
c.rendered()
|
|
369
|
+
else:
|
|
370
|
+
self.tx.resume()
|
|
371
|
+
|
|
372
|
+
def _returned(self, *args: Any) -> None:
|
|
373
|
+
if not self.started:
|
|
374
|
+
return self._error(Pyjo.Exception.new('ASGI application returned without a response'))
|
|
375
|
+
if not self.ended and not self.closed:
|
|
376
|
+
self.app.log.error('ASGI application returned without the end of its response')
|
|
377
|
+
self._close()
|
|
378
|
+
|
|
379
|
+
async def _send(self, message: Message) -> None:
|
|
380
|
+
if self.closed:
|
|
381
|
+
return
|
|
382
|
+
type = message['type']
|
|
383
|
+
|
|
384
|
+
# Status and headers
|
|
385
|
+
if type == 'http.response.start':
|
|
386
|
+
if self.status is not None:
|
|
387
|
+
raise Pyjo.Exception.new('ASGI message "http.response.start" has already been sent')
|
|
388
|
+
self.status = int(message['status'])
|
|
389
|
+
self.headers = [
|
|
390
|
+
(bytes(name).decode('latin-1'), bytes(value).decode('latin-1')) for name, value in message['headers']
|
|
391
|
+
]
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
if type != 'http.response.body':
|
|
395
|
+
raise Pyjo.Exception.new(f'Unknown ASGI message "{type}"')
|
|
396
|
+
if self.status is None:
|
|
397
|
+
raise Pyjo.Exception.new('ASGI message "http.response.start" has not been sent')
|
|
398
|
+
if self.ended:
|
|
399
|
+
# A HEAD request, whose response ends with its headers
|
|
400
|
+
if self.tx.is_empty:
|
|
401
|
+
return
|
|
402
|
+
raise Pyjo.Exception.new('ASGI response has already ended')
|
|
403
|
+
body = bytes(message.get('body', b''))
|
|
404
|
+
more = bool(message.get('more_body', False))
|
|
405
|
+
|
|
406
|
+
# Whole response
|
|
407
|
+
if not self.started:
|
|
408
|
+
self._headers(not more)
|
|
409
|
+
if not more:
|
|
410
|
+
self.ended = True
|
|
411
|
+
self.tx.res.body = body
|
|
412
|
+
return self._rendered()
|
|
413
|
+
|
|
414
|
+
# Response as the application sends it, with the headers only for a HEAD request
|
|
415
|
+
if self.tx.is_empty:
|
|
416
|
+
return self._end()
|
|
417
|
+
if len(body):
|
|
418
|
+
written = self.written = Pyjo.Promise.new()
|
|
419
|
+
content = self.tx.res.content
|
|
420
|
+
|
|
421
|
+
def drained(*args: Any) -> None:
|
|
422
|
+
written.resolve()
|
|
423
|
+
|
|
424
|
+
(content.write_chunk if self.chunked else content.write)(body, drained)
|
|
425
|
+
self._rendered()
|
|
426
|
+
await written
|
|
427
|
+
if not more and not self.closed:
|
|
428
|
+
self._end()
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# A call of the application for a WebSocket, whose handshake waits for websocket.accept, and whose response of HTTP that
|
|
432
|
+
# rejects the handshake is the one of a call for a request
|
|
433
|
+
class _WebSocket(_Call):
|
|
434
|
+
def __init__(self, app: Pyjo_App_ASGI, application: Application, ws: Any, c: Any) -> None:
|
|
435
|
+
super().__init__(app, application, ws.handshake, c)
|
|
436
|
+
self.accepted = False
|
|
437
|
+
self.closing = False
|
|
438
|
+
self.connected = False
|
|
439
|
+
# A deque, whose popleft takes the first message, where pop(0) of a list moves all the others
|
|
440
|
+
self.messages: collections.deque[Message] = collections.deque()
|
|
441
|
+
self.responding = False
|
|
442
|
+
self.waiting: Pyjo.Promise.object | None = None
|
|
443
|
+
self.ws = ws
|
|
444
|
+
|
|
445
|
+
def start(self, lifespan: '_Lifespan') -> None:
|
|
446
|
+
ws = self.ws
|
|
447
|
+
ws.on('message', self._message)
|
|
448
|
+
ws.on('finish', self._disconnect)
|
|
449
|
+
if lifespan.failed is not None:
|
|
450
|
+
return self._error(Pyjo.Exception.new(f'ASGI application failed to start: {lifespan.failed}'))
|
|
451
|
+
ws.heartbeat(self.app.ping_interval, self.app.ping_timeout)
|
|
452
|
+
try:
|
|
453
|
+
coro = self.application(lifespan.scope(_scope(self.tx, self.c, ws)), self._receive, self._send)
|
|
454
|
+
except Exception as e:
|
|
455
|
+
return self._error(e)
|
|
456
|
+
Pyjo.Promise.new(coro).then(self._returned, self._error)
|
|
457
|
+
|
|
458
|
+
# The end of the WebSocket, also when the connection is gone before its handshake, after which receive gives
|
|
459
|
+
# websocket.disconnect and send does nothing
|
|
460
|
+
def _disconnect(self, ws: Any, code: int | None, reason: str | None) -> None:
|
|
461
|
+
self.messages.append({'type': 'websocket.disconnect', 'code': code or 1005, 'reason': reason or ''})
|
|
462
|
+
self._finish()
|
|
463
|
+
self._wake()
|
|
464
|
+
|
|
465
|
+
def _error(self, e: Any) -> None:
|
|
466
|
+
if not self.accepted:
|
|
467
|
+
return super()._error(e)
|
|
468
|
+
|
|
469
|
+
# The WebSocket is closed with the status of an error of the server, unless the application has closed it
|
|
470
|
+
if not self.closed:
|
|
471
|
+
self.app.log.error(f'ASGI application failed: {_message(e)}')
|
|
472
|
+
self._close_ws(1011)
|
|
473
|
+
|
|
474
|
+
def _message(self, ws: Any, message: str | bytes) -> None:
|
|
475
|
+
self.messages.append(
|
|
476
|
+
{'type': 'websocket.receive', 'text': message}
|
|
477
|
+
if isinstance(message, str)
|
|
478
|
+
else {'type': 'websocket.receive', 'bytes': message}
|
|
479
|
+
)
|
|
480
|
+
self._wake()
|
|
481
|
+
|
|
482
|
+
async def _receive(self) -> Message:
|
|
483
|
+
if not self.connected:
|
|
484
|
+
self.connected = True
|
|
485
|
+
return {'type': 'websocket.connect'}
|
|
486
|
+
while not self.messages:
|
|
487
|
+
self.waiting = Pyjo.Promise.new()
|
|
488
|
+
await self.waiting
|
|
489
|
+
return self.messages.popleft()
|
|
490
|
+
|
|
491
|
+
def _returned(self, *args: Any) -> None:
|
|
492
|
+
if self.closed and not self.accepted:
|
|
493
|
+
return
|
|
494
|
+
if not self.accepted:
|
|
495
|
+
return super()._returned(*args)
|
|
496
|
+
if not self.closed and not self.closing:
|
|
497
|
+
self.app.log.error('ASGI application returned without closing its WebSocket')
|
|
498
|
+
self._close_ws(1011)
|
|
499
|
+
|
|
500
|
+
async def _send(self, message: Message) -> None:
|
|
501
|
+
if self.closed or self.closing:
|
|
502
|
+
return
|
|
503
|
+
type = message['type']
|
|
504
|
+
|
|
505
|
+
# The response of HTTP that rejects the handshake
|
|
506
|
+
if self.responding or type == 'websocket.http.response.start':
|
|
507
|
+
if not type.startswith('websocket.http.response.'):
|
|
508
|
+
raise Pyjo.Exception.new(f'ASGI message "{type}" after "websocket.http.response.start"')
|
|
509
|
+
self.responding = True
|
|
510
|
+
return await super()._send({**message, 'type': type.removeprefix('websocket.')})
|
|
511
|
+
|
|
512
|
+
# Handshake
|
|
513
|
+
if not self.accepted:
|
|
514
|
+
if type == 'websocket.accept':
|
|
515
|
+
self.accepted = True
|
|
516
|
+
ws = self.ws
|
|
517
|
+
headers = ws.res.headers
|
|
518
|
+
for name, value in message.get('headers', []):
|
|
519
|
+
headers.add(bytes(name).decode('latin-1'), bytes(value).decode('latin-1'))
|
|
520
|
+
if (protocol := message.get('subprotocol')) is not None:
|
|
521
|
+
headers.sec_websocket_protocol = protocol
|
|
522
|
+
ws.with_compression()
|
|
523
|
+
return self._accepted()
|
|
524
|
+
|
|
525
|
+
# Rejected
|
|
526
|
+
if type == 'websocket.close':
|
|
527
|
+
self.started = self.ended = True
|
|
528
|
+
res = self.tx.res
|
|
529
|
+
res.code = 403
|
|
530
|
+
res.body = b''
|
|
531
|
+
return self._rendered()
|
|
532
|
+
|
|
533
|
+
raise Pyjo.Exception.new(f'ASGI message "{type}" before "websocket.accept"')
|
|
534
|
+
|
|
535
|
+
# Message
|
|
536
|
+
if type == 'websocket.send':
|
|
537
|
+
written = self.written = Pyjo.Promise.new()
|
|
538
|
+
|
|
539
|
+
def drained(*args: Any) -> None:
|
|
540
|
+
written.resolve()
|
|
541
|
+
|
|
542
|
+
if (data := message.get('bytes')) is not None:
|
|
543
|
+
self.ws.send({'binary': bytes(data)}, drained)
|
|
544
|
+
else:
|
|
545
|
+
self.ws.send(message.get('text') or '', drained)
|
|
546
|
+
await written
|
|
547
|
+
return
|
|
548
|
+
|
|
549
|
+
# Close
|
|
550
|
+
if type == 'websocket.close':
|
|
551
|
+
return self._close_ws(int(message.get('code') or 1000), message.get('reason') or None)
|
|
552
|
+
|
|
553
|
+
raise Pyjo.Exception.new(f'Unknown ASGI message "{type}"')
|
|
554
|
+
|
|
555
|
+
# The response 101 of the handshake
|
|
556
|
+
def _accepted(self) -> None:
|
|
557
|
+
if (c := self.c) is not None:
|
|
558
|
+
c.rendered(101)
|
|
559
|
+
else:
|
|
560
|
+
self.tx.res.code = 101
|
|
561
|
+
self.ws.resume()
|
|
562
|
+
|
|
563
|
+
def _close_ws(self, code: int, reason: str | None = None) -> None:
|
|
564
|
+
if not self.closing:
|
|
565
|
+
self.closing = True
|
|
566
|
+
self.ws.finish(code, reason)
|
|
567
|
+
|
|
568
|
+
def _wake(self) -> None:
|
|
569
|
+
if (waiting := self.waiting) is not None:
|
|
570
|
+
self.waiting = None
|
|
571
|
+
waiting.resolve()
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
# The lifespan of the application in an event loop, whose state the requests get a copy of, or nothing for an
|
|
575
|
+
# application without lifespan, which fails or returns before its startup, as uvicorn takes it
|
|
576
|
+
class _Lifespan:
|
|
577
|
+
def __init__(self, app: Pyjo_App_ASGI, application: Application) -> None:
|
|
578
|
+
self.app = app
|
|
579
|
+
self.application = application
|
|
580
|
+
self.failed: str | None = None
|
|
581
|
+
self.loop = Pyjo.IOLoop.object.singleton()
|
|
582
|
+
self.ready = False
|
|
583
|
+
self.received = False
|
|
584
|
+
self.requested = Pyjo.Promise.new()
|
|
585
|
+
self.started = Pyjo.Promise.new()
|
|
586
|
+
self.state: dict[str, Any] = {}
|
|
587
|
+
self.stopped = Pyjo.Promise.new()
|
|
588
|
+
self.supported = True
|
|
589
|
+
|
|
590
|
+
def scope(self, scope: Scope) -> Scope:
|
|
591
|
+
return {**scope, 'state': dict(self.state)} if self.supported else scope
|
|
592
|
+
|
|
593
|
+
def shutdown(self) -> Pyjo.Promise.object:
|
|
594
|
+
_running.discard(self)
|
|
595
|
+
if self.ready:
|
|
596
|
+
self.requested.resolve()
|
|
597
|
+
else:
|
|
598
|
+
self.started.then(lambda *args: self.requested.resolve())
|
|
599
|
+
return self.stopped
|
|
600
|
+
|
|
601
|
+
def start(self) -> None:
|
|
602
|
+
_exit()
|
|
603
|
+
_running.add(self)
|
|
604
|
+
scope = {'type': 'lifespan', 'asgi': {'version': '3.0', 'spec_version': '2.0'}, 'state': self.state}
|
|
605
|
+
try:
|
|
606
|
+
coro = self.application(scope, self._receive, self._send)
|
|
607
|
+
except Exception as e:
|
|
608
|
+
return self._error(e)
|
|
609
|
+
Pyjo.Promise.new(coro).then(self._returned, self._error)
|
|
610
|
+
|
|
611
|
+
def _end(self) -> None:
|
|
612
|
+
self._ready()
|
|
613
|
+
_running.discard(self)
|
|
614
|
+
self.stopped.resolve()
|
|
615
|
+
|
|
616
|
+
def _error(self, e: Any) -> None:
|
|
617
|
+
if not self.ready and self.failed is None:
|
|
618
|
+
self.supported = False
|
|
619
|
+
self.app.log.debug(f'ASGI application without lifespan: {e!r}')
|
|
620
|
+
elif self.ready:
|
|
621
|
+
self.app.log.error(f'ASGI application failed: {_message(e)}')
|
|
622
|
+
self._end()
|
|
623
|
+
|
|
624
|
+
def _ready(self) -> None:
|
|
625
|
+
if not self.ready:
|
|
626
|
+
self.ready = True
|
|
627
|
+
self.started.resolve()
|
|
628
|
+
|
|
629
|
+
async def _receive(self) -> Message:
|
|
630
|
+
if not self.received:
|
|
631
|
+
self.received = True
|
|
632
|
+
return {'type': 'lifespan.startup'}
|
|
633
|
+
await self.requested
|
|
634
|
+
return {'type': 'lifespan.shutdown'}
|
|
635
|
+
|
|
636
|
+
def _returned(self, *args: Any) -> None:
|
|
637
|
+
if not self.ready:
|
|
638
|
+
self.supported = False
|
|
639
|
+
self._end()
|
|
640
|
+
|
|
641
|
+
async def _send(self, message: Message) -> None:
|
|
642
|
+
type = message['type']
|
|
643
|
+
if type == 'lifespan.startup.complete':
|
|
644
|
+
self._ready()
|
|
645
|
+
elif type == 'lifespan.startup.failed':
|
|
646
|
+
self.failed = str(message.get('message') or '')
|
|
647
|
+
self.app.log.error(f'ASGI application failed to start: {self.failed}')
|
|
648
|
+
self._ready()
|
|
649
|
+
elif type == 'lifespan.shutdown.complete':
|
|
650
|
+
self._end()
|
|
651
|
+
elif type == 'lifespan.shutdown.failed':
|
|
652
|
+
self.app.log.error(f'ASGI application failed to stop: {message.get("message") or ""}')
|
|
653
|
+
self._end()
|
|
654
|
+
else:
|
|
655
|
+
raise Pyjo.Exception.new(f'Unknown ASGI message "{type}"')
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
# The lifespans that have started, which the end of the interpreter shuts down, where no server has stopped, as at the
|
|
659
|
+
# end of a test, before the reactor of asyncio cancels the tasks that are left, as its handler of the end of the
|
|
660
|
+
# interpreter is registered before
|
|
661
|
+
_running: weakref.WeakSet[_Lifespan] = weakref.WeakSet()
|
|
662
|
+
_registered: list[bool] = []
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _exit() -> None:
|
|
666
|
+
if not _registered:
|
|
667
|
+
_registered.append(True)
|
|
668
|
+
atexit.register(_shutdown)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _shutdown() -> None:
|
|
672
|
+
try:
|
|
673
|
+
loop = Pyjo.IOLoop.object.singleton()
|
|
674
|
+
for lifespan in list(_running):
|
|
675
|
+
if lifespan.loop is loop and not loop.is_running:
|
|
676
|
+
lifespan.shutdown().wait()
|
|
677
|
+
except Exception:
|
|
678
|
+
pass
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
# The connection scope of the request of the transaction, or of the handshake of a WebSocket: the path that a route
|
|
682
|
+
# embeds the application under is root_path, which path begins with
|
|
683
|
+
def _scope(tx: Any, c: Any, ws: Any = None) -> Scope:
|
|
684
|
+
req = tx.req
|
|
685
|
+
url = req.url
|
|
686
|
+
base = url.base
|
|
687
|
+
script, path = Pyjo.App._mount(tx, c) # pyright: ignore[reportPrivateUsage]
|
|
688
|
+
|
|
689
|
+
# Headers, with the length of the body, which is whole, as it has been read
|
|
690
|
+
headers = [
|
|
691
|
+
(name.lower().encode('latin-1'), value.encode('latin-1'))
|
|
692
|
+
for name, value in req.headers.to_list()
|
|
693
|
+
if name.lower() not in ('content-length', 'transfer-encoding')
|
|
694
|
+
]
|
|
695
|
+
if size := req.content.body_size:
|
|
696
|
+
headers.append((b'content-length', str(size).encode()))
|
|
697
|
+
|
|
698
|
+
https = req.is_secure
|
|
699
|
+
scope: dict[str, Any] = {
|
|
700
|
+
'type': 'http' if ws is None else 'websocket',
|
|
701
|
+
'asgi': {'version': '3.0', 'spec_version': '2.4'},
|
|
702
|
+
'http_version': req.version,
|
|
703
|
+
'scheme': ('https' if https else 'http') if ws is None else ('wss' if https else 'ws'),
|
|
704
|
+
'path': _str(script + path),
|
|
705
|
+
'raw_path': (script + path).encode(),
|
|
706
|
+
'query_string': url.query.to_str().encode(),
|
|
707
|
+
'root_path': _str(script),
|
|
708
|
+
'headers': headers,
|
|
709
|
+
'server': (base.host or tx.local_address or 'localhost', base.port or tx.local_port or (443 if https else 80)),
|
|
710
|
+
}
|
|
711
|
+
if ws is None:
|
|
712
|
+
scope['method'] = req.method
|
|
713
|
+
else:
|
|
714
|
+
# The subprotocols that the client offers, and the response of HTTP that rejects the handshake
|
|
715
|
+
offered = req.headers.sec_websocket_protocol or ''
|
|
716
|
+
scope['subprotocols'] = [protocol.strip() for protocol in offered.split(',') if protocol.strip()]
|
|
717
|
+
scope['extensions'] = {'websocket.http.response': {}}
|
|
718
|
+
if (address := tx.remote_address) is not None:
|
|
719
|
+
scope['client'] = (address, tx.remote_port or 0)
|
|
720
|
+
return scope
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
# An exception with its traceback, for the log
|
|
724
|
+
def _message(e: Any) -> str:
|
|
725
|
+
return ''.join(traceback.format_exception(e)).rstrip() if isinstance(e, BaseException) else str(e)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
# A path of ASGI: the unescaped bytes as UTF-8
|
|
729
|
+
def _str(path: str) -> str:
|
|
730
|
+
return url_unescape(path.encode()).decode('utf-8', 'replace')
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
new = Pyjo_App_ASGI.new
|
|
734
|
+
object = Pyjo_App_ASGI
|
|
735
|
+
ASGI = Pyjo_App_ASGI
|