Pyjoyment 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. Pyjo/App/ASGI.py +735 -0
  2. Pyjo/App/CGI.py +773 -0
  3. Pyjo/App/FastCGI.py +631 -0
  4. Pyjo/App/Lambda.py +987 -0
  5. Pyjo/App/WSGI.py +427 -0
  6. Pyjo/App/__init__.py +409 -0
  7. Pyjo/Asset/File.py +377 -0
  8. Pyjo/Asset/Memory.py +235 -0
  9. Pyjo/Asset/__init__.py +200 -0
  10. Pyjo/Base.py +215 -0
  11. Pyjo/Cache.py +119 -0
  12. Pyjo/Collection.py +570 -0
  13. Pyjo/Content/MultiPart.py +325 -0
  14. Pyjo/Content/Single.py +193 -0
  15. Pyjo/Content/__init__.py +806 -0
  16. Pyjo/Cookie/Request.py +90 -0
  17. Pyjo/Cookie/Response.py +294 -0
  18. Pyjo/Cookie/__init__.py +143 -0
  19. Pyjo/DOM/CSS.py +1019 -0
  20. Pyjo/DOM/HTML.py +550 -0
  21. Pyjo/DOM/__init__.py +1329 -0
  22. Pyjo/Date.py +294 -0
  23. Pyjo/EventEmitter.py +522 -0
  24. Pyjo/Exception.py +494 -0
  25. Pyjo/File.py +959 -0
  26. Pyjo/GetOpt.py +605 -0
  27. Pyjo/Headers.py +760 -0
  28. Pyjo/HelloWorld.py +77 -0
  29. Pyjo/Home.py +109 -0
  30. Pyjo/IOLoop/ChildProcess.py +591 -0
  31. Pyjo/IOLoop/Client.py +736 -0
  32. Pyjo/IOLoop/Datagram.py +982 -0
  33. Pyjo/IOLoop/Messages.py +664 -0
  34. Pyjo/IOLoop/Server.py +458 -0
  35. Pyjo/IOLoop/Stdio.py +446 -0
  36. Pyjo/IOLoop/Stream.py +644 -0
  37. Pyjo/IOLoop/Subprocess.py +419 -0
  38. Pyjo/IOLoop/TLS.py +360 -0
  39. Pyjo/IOLoop/__init__.py +1036 -0
  40. Pyjo/IOLoop/certs/README.md +41 -0
  41. Pyjo/IOLoop/certs/ca.crt +20 -0
  42. Pyjo/IOLoop/certs/server.crt +21 -0
  43. Pyjo/IOLoop/certs/server.key +28 -0
  44. Pyjo/JSON/Pointer.py +135 -0
  45. Pyjo/JSON/RPC.py +772 -0
  46. Pyjo/JSON/Schema.py +2506 -0
  47. Pyjo/JSON/__init__.py +573 -0
  48. Pyjo/Loader.py +342 -0
  49. Pyjo/Log.py +723 -0
  50. Pyjo/Message/Request.py +725 -0
  51. Pyjo/Message/Response.py +476 -0
  52. Pyjo/Message/__init__.py +830 -0
  53. Pyjo/Parameters.py +472 -0
  54. Pyjo/Path.py +419 -0
  55. Pyjo/Promise.py +894 -0
  56. Pyjo/Prove.py +1305 -0
  57. Pyjo/Reactor/Asyncio.py +854 -0
  58. Pyjo/Reactor/Poll.py +439 -0
  59. Pyjo/Reactor/__init__.py +525 -0
  60. Pyjo/SSE.py +119 -0
  61. Pyjo/Server/ASGI.py +486 -0
  62. Pyjo/Server/CGI.py +259 -0
  63. Pyjo/Server/Daemon.py +1007 -0
  64. Pyjo/Server/FastCGI.py +517 -0
  65. Pyjo/Server/Hot/__init__.py +687 -0
  66. Pyjo/Server/Hot/__main__.py +5 -0
  67. Pyjo/Server/Lambda.py +641 -0
  68. Pyjo/Server/Prefork.py +917 -0
  69. Pyjo/Server/Prethread.py +394 -0
  70. Pyjo/Server/Reload/Backend/Poll.py +114 -0
  71. Pyjo/Server/Reload/Backend/__init__.py +126 -0
  72. Pyjo/Server/Reload/__init__.py +482 -0
  73. Pyjo/Server/Reload/__main__.py +5 -0
  74. Pyjo/Server/Stdio.py +228 -0
  75. Pyjo/Server/WSGI.py +349 -0
  76. Pyjo/Server/__init__.py +396 -0
  77. Pyjo/String/Bytes.py +532 -0
  78. Pyjo/String/HTML.py +210 -0
  79. Pyjo/String/Mixin.py +333 -0
  80. Pyjo/String/Template.py +470 -0
  81. Pyjo/String/Unicode.py +553 -0
  82. Pyjo/String/__init__.py +0 -0
  83. Pyjo/TOML.py +232 -0
  84. Pyjo/Template.py +1290 -0
  85. Pyjo/Test/Pyjo.py +1454 -0
  86. Pyjo/Test/__init__.py +1233 -0
  87. Pyjo/Test/pytest_plugin.py +305 -0
  88. Pyjo/Transaction/HTTP.py +374 -0
  89. Pyjo/Transaction/WebSocket.py +709 -0
  90. Pyjo/Transaction/__init__.py +429 -0
  91. Pyjo/URL/Template.py +459 -0
  92. Pyjo/URL/__init__.py +651 -0
  93. Pyjo/Upload.py +139 -0
  94. Pyjo/UserAgent/CookieJar.py +391 -0
  95. Pyjo/UserAgent/Proxy.py +138 -0
  96. Pyjo/UserAgent/Server.py +239 -0
  97. Pyjo/UserAgent/Transactor.py +766 -0
  98. Pyjo/UserAgent/__init__.py +1243 -0
  99. Pyjo/Util.py +3567 -0
  100. Pyjo/WebSocket.py +332 -0
  101. Pyjo/YAML.py +1167 -0
  102. Pyjo/__init__.py +441 -0
  103. Pyjo/__main__.py +35 -0
  104. Pyjo/py.typed +0 -0
  105. Pyjoyment/Command/Author/__init__.py +20 -0
  106. Pyjoyment/Command/Author/generate/__init__.py +106 -0
  107. Pyjoyment/Command/Author/generate/app.py +299 -0
  108. Pyjoyment/Command/Author/generate/dockerfile.py +281 -0
  109. Pyjoyment/Command/Author/generate/kubernetes.py +514 -0
  110. Pyjoyment/Command/Author/generate/lambda_zip.py +372 -0
  111. Pyjoyment/Command/Author/generate/lite_app.py +113 -0
  112. Pyjoyment/Command/Author/generate/plugin.py +267 -0
  113. Pyjoyment/Command/Author/generate/pyproject.py +123 -0
  114. Pyjoyment/Command/Author/inflate.py +101 -0
  115. Pyjoyment/Command/__init__.py +332 -0
  116. Pyjoyment/Command/asgi.py +92 -0
  117. Pyjoyment/Command/cgi.py +90 -0
  118. Pyjoyment/Command/daemon.py +160 -0
  119. Pyjoyment/Command/eval.py +128 -0
  120. Pyjoyment/Command/fastcgi.py +99 -0
  121. Pyjoyment/Command/get.py +305 -0
  122. Pyjoyment/Command/lambda.py +117 -0
  123. Pyjoyment/Command/prefork.py +181 -0
  124. Pyjoyment/Command/prethread.py +126 -0
  125. Pyjoyment/Command/routes.py +133 -0
  126. Pyjoyment/Command/stdio.py +92 -0
  127. Pyjoyment/Command/version.py +141 -0
  128. Pyjoyment/Command/wsgi.py +93 -0
  129. Pyjoyment/Commands.py +546 -0
  130. Pyjoyment/Controller.py +1307 -0
  131. Pyjoyment/HelloWorld.py +56 -0
  132. Pyjoyment/Lite.py +494 -0
  133. Pyjoyment/Plugin/AllowedHosts.py +205 -0
  134. Pyjoyment/Plugin/Config.py +248 -0
  135. Pyjoyment/Plugin/DefaultHelpers.py +1202 -0
  136. Pyjoyment/Plugin/EPLRenderer.py +134 -0
  137. Pyjoyment/Plugin/EPRenderer.py +212 -0
  138. Pyjoyment/Plugin/HeaderCondition.py +98 -0
  139. Pyjoyment/Plugin/Health.py +325 -0
  140. Pyjoyment/Plugin/JSONConfig.py +172 -0
  141. Pyjoyment/Plugin/Mount.py +223 -0
  142. Pyjoyment/Plugin/PyRenderer.py +209 -0
  143. Pyjoyment/Plugin/TOMLConfig.py +141 -0
  144. Pyjoyment/Plugin/TagHelpers.py +959 -0
  145. Pyjoyment/Plugin/YAMLConfig.py +178 -0
  146. Pyjoyment/Plugin/__init__.py +80 -0
  147. Pyjoyment/Plugins.py +215 -0
  148. Pyjoyment/Renderer.py +673 -0
  149. Pyjoyment/Routes/Match.py +251 -0
  150. Pyjoyment/Routes/Pattern.py +443 -0
  151. Pyjoyment/Routes/Route.py +827 -0
  152. Pyjoyment/Routes/__init__.py +419 -0
  153. Pyjoyment/Sessions.py +255 -0
  154. Pyjoyment/Static.py +421 -0
  155. Pyjoyment/Types.py +238 -0
  156. Pyjoyment/Validator/Validation.py +395 -0
  157. Pyjoyment/Validator/__init__.py +304 -0
  158. Pyjoyment/__init__.py +1083 -0
  159. Pyjoyment/__main__.py +5 -0
  160. Pyjoyment/py.typed +0 -0
  161. Pyjoyment/resources/public/favicon.ico +0 -0
  162. Pyjoyment/resources/public/favicon.svg +345 -0
  163. Pyjoyment/resources/public/pyjo/bootstrap/LICENSE +21 -0
  164. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.bundle.min.js.map +1 -0
  165. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.css +6 -0
  166. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.js +7 -0
  167. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.min.css.map +1 -0
  168. Pyjoyment/resources/public/pyjo/failunicat.png +0 -0
  169. Pyjoyment/resources/public/pyjo/failunicat.svg +50 -0
  170. Pyjoyment/resources/public/pyjo/highlight.js/LICENSE +29 -0
  171. Pyjoyment/resources/public/pyjo/highlight.js/highlight-mojo-dark.css +56 -0
  172. Pyjoyment/resources/public/pyjo/highlight.js/highlight.min.js +1262 -0
  173. Pyjoyment/resources/public/pyjo/highlight.js/pyjoyment.min.js +1 -0
  174. Pyjoyment/resources/public/pyjo/logo-black-4x.png +0 -0
  175. Pyjoyment/resources/public/pyjo/logo-black-4x.svg +346 -0
  176. Pyjoyment/resources/public/pyjo/logo-white-2x.png +0 -0
  177. Pyjoyment/resources/public/pyjo/logo-white-2x.svg +346 -0
  178. Pyjoyment/resources/public/pyjo/logo-white-4x.png +0 -0
  179. Pyjoyment/resources/public/pyjo/logo-white-4x.svg +346 -0
  180. Pyjoyment/resources/public/pyjo/logo-white.png +0 -0
  181. Pyjoyment/resources/public/pyjo/logo-white.svg +346 -0
  182. Pyjoyment/resources/public/pyjo/logo.png +0 -0
  183. Pyjoyment/resources/public/pyjo/logo.svg +346 -0
  184. Pyjoyment/resources/public/pyjo/notfound.png +0 -0
  185. Pyjoyment/resources/public/pyjo/notfound.svg +3 -0
  186. Pyjoyment/resources/public/pyjo/nounicat.png +0 -0
  187. Pyjoyment/resources/public/pyjo/nounicat.svg +5 -0
  188. Pyjoyment/resources/public/pyjo/pinstripe-dark.png +0 -0
  189. Pyjoyment/resources/public/pyjo/pinstripe-dark.svg +4 -0
  190. Pyjoyment/resources/public/pyjo/pinstripe-light.png +0 -0
  191. Pyjoyment/resources/public/pyjo/pinstripe-light.svg +4 -0
  192. Pyjoyment/resources/public/pyjo/pyjo.css +204 -0
  193. Pyjoyment/resources/templates/pyjo/debug.html.ep +346 -0
  194. Pyjoyment/resources/templates/pyjo/exception.html.ep +32 -0
  195. Pyjoyment/resources/templates/pyjo/not_found.html.ep +45 -0
  196. pyjoyment-0.1.0.dist-info/METADATA +376 -0
  197. pyjoyment-0.1.0.dist-info/RECORD +200 -0
  198. pyjoyment-0.1.0.dist-info/WHEEL +4 -0
  199. pyjoyment-0.1.0.dist-info/entry_points.txt +8 -0
  200. pyjoyment-0.1.0.dist-info/licenses/LICENSE +201 -0
Pyjo/App/CGI.py ADDED
@@ -0,0 +1,773 @@
1
+ """
2
+ Pyjo.App.CGI - CGI application
3
+ ==============================
4
+ ::
5
+
6
+ from Pyjo.App.CGI import CGI
7
+
8
+ app = CGI(script='/usr/lib/cgi-bin/hello.cgi')
9
+
10
+ # Or import the module, as in Google Python Style Guide
11
+
12
+ import Pyjo.App.CGI
13
+
14
+ app = Pyjo.App.CGI.new(script='/usr/lib/cgi-bin/hello.cgi')
15
+
16
+ # The programs of a directory, as cgi-bin
17
+ app = Pyjo.App.CGI.new(document_root='/usr/lib/cgi-bin')
18
+
19
+ # PHP on php-cgi, with the files of its directory
20
+ app = Pyjo.App.CGI.new(
21
+ command=['php-cgi'], document_root='/var/www', script_extensions=['.php'], static=True
22
+ )
23
+
24
+ # Mount it in an application of Pyjoyment
25
+ import Pyjoyment
26
+
27
+ pyjoyment = Pyjoyment.new()
28
+ pyjoyment.plugin('Mount', {'/cgi-bin': app})
29
+
30
+ :mod:`Pyjo.App.CGI` is an application of :mod:`Pyjo` that runs a ``CGI``
31
+ program, as :rfc:`3875` describes it, in a process of its own for each
32
+ request, which :mod:`Pyjo.IOLoop.ChildProcess` runs, so that a server of :mod:`Pyjo` serves it, a route of
33
+ :mod:`Pyjoyment` embeds it, as :mod:`Pyjoyment.Plugin.Mount` does, and
34
+ :mod:`Pyjo.Test.Pyjo` tests it.
35
+
36
+ The environment of the process is the one of ``CGI`` of the request: the part
37
+ of the path that a route embeds the application under is ``SCRIPT_NAME``, after
38
+ the base path of the request, which a part of the path that does not end with
39
+ a slash, as ``/cgi`` of ``/cgix``, cannot be, so that the application is not
40
+ found there, the rest of the path is ``PATH_INFO``, ``REQUEST_URI`` is the path
41
+ with the query, without the header ``Proxy``, whose ``HTTP_PROXY`` a program
42
+ may take for the proxy of its own requests, as with httpoxy, with ``PATH`` of
43
+ the process of Pyjoyment, and :attr:`env` adds to them. The program runs in
44
+ the directory of its file, and the body of the request goes to its standard
45
+ input, each chunk once the one before has been written.
46
+
47
+ The standard output of the program is its response of ``CGI``, read through
48
+ the event loop, as it comes. A response that comes whole, before the program
49
+ closes its standard output, is written with its ``Content-Length``. Another one
50
+ is written as it comes, each chunk once the one before has been written to the
51
+ client, with the chunked transfer encoding when it does not give its
52
+ ``Content-Length``. What the program writes to its standard error is logged.
53
+
54
+ A program that does not start or ends before its response is a response
55
+ ``502``, and one that ends otherwise after the start of the response closes
56
+ the connection of the client, as one that is quiet for longer than
57
+ :attr:`inactivity_timeout` does, which, until the response starts, the
58
+ connection of the client waits for without the inactivity timeout of the
59
+ server. The process of a request whose client is gone is killed, with the
60
+ processes that it has started, as it runs as the leader of a process group.
61
+
62
+ The reactor watches the pipes of the process, which it can on POSIX systems,
63
+ not on Windows, where the response is ``502``.
64
+
65
+ Attributes
66
+ ----------
67
+
68
+ :mod:`Pyjo.App.CGI` inherits all attributes from :mod:`Pyjo.App` and
69
+ implements the following new ones.
70
+
71
+ See also
72
+ --------
73
+
74
+ :mod:`Pyjoyment`, :mod:`Pyjoyment.Plugin.Mount`, :mod:`Pyjo.App.FastCGI`,
75
+ :mod:`Pyjo.IOLoop.ChildProcess`, :mod:`Pyjo.Server.CGI`, https://mojolicious.org.
76
+
77
+ It has no counterpart in Mojolicious; it is the reverse of
78
+ :mod:`Pyjo.Server.CGI`, inspired by ``Mojolicious::Plugin::CGI`` of CPAN.
79
+
80
+ Classes
81
+ -------
82
+ """
83
+
84
+ import Pyjo.App
85
+ import Pyjo.Exception
86
+ import Pyjo.IOLoop
87
+ import Pyjo.IOLoop.ChildProcess
88
+ import Pyjo.Message.Response
89
+ import Pyjo.Transaction
90
+
91
+ import os
92
+ import re
93
+ import signal
94
+ import wsgiref.util
95
+
96
+ from collections.abc import Sequence
97
+ from typing import Any
98
+
99
+
100
+ # Directories of the files of X-Sendfile, or regular expressions of their paths
101
+ type Place = str | os.PathLike[str] | re.Pattern[str]
102
+ type Sendfile = Place | Sequence[Place]
103
+
104
+ # The headers of a response by X-Sendfile that the file takes the place of
105
+ _SENT = ('content-length', 'x-sendfile')
106
+
107
+
108
+ class Pyjo_App_CGI(Pyjo.App.object):
109
+ """
110
+ :mod:`Pyjo.App.CGI` inherits all methods from :mod:`Pyjo.App` and
111
+ implements the following new ones.
112
+ """
113
+
114
+ def __init__(self, attrs: dict[str, Any] | None = None, /, **kwargs: Any) -> None:
115
+ """::
116
+
117
+ app = Pyjo.App.CGI.new(script='/usr/lib/cgi-bin/hello.cgi')
118
+
119
+ Construct a new :mod:`Pyjo.App.CGI` object.
120
+ """
121
+ self.command: list[str] | None = None
122
+ """::
123
+
124
+ command = app.command
125
+ app.command = ['php-cgi']
126
+
127
+ Command that runs the script, with its file as the last argument, as an
128
+ interpreter, the script itself otherwise, which has to be executable.
129
+ With it, ``REDIRECT_STATUS`` is ``200``, as Apache gives it to the
130
+ command of ``Action``, without which php-cgi runs no script.
131
+ """
132
+
133
+ self.document_root: str | os.PathLike[str] | None = None
134
+ """::
135
+
136
+ root = app.document_root
137
+ app.document_root = '/usr/lib/cgi-bin'
138
+
139
+ Directory of the scripts, which takes the script of each request from
140
+ its path, as a web server does: the path after the mount, up to its
141
+ first part that is a file of the directory, or that ends with one of
142
+ :attr:`script_extensions`, names the script, whose file is
143
+ ``SCRIPT_FILENAME`` and whose path after the mount is ``SCRIPT_NAME``,
144
+ the rest of the path is ``PATH_INFO``, and a path that ends with a
145
+ slash names ``index`` with the first of :attr:`script_extensions` in
146
+ it, as ``index.php``. A path with ``..``, or that names no file, or no
147
+ script of :attr:`script_extensions`, is not found. With :attr:`script`, a path that names no script of the
148
+ directory is the one of :attr:`script`, as the one entry of an
149
+ application that takes every path, as with ``try_files`` of nginx.
150
+ ``SCRIPT_FILENAME`` of :attr:`env` takes its place.
151
+ """
152
+
153
+ self.env: dict[str, str] = {}
154
+ """::
155
+
156
+ env = app.env
157
+ app.env = {'PERL5LIB': '/usr/local/lib/perl5'}
158
+
159
+ Variables that are added to the environment of ``CGI`` of each
160
+ request, and replace the ones of the same name.
161
+ """
162
+
163
+ self.inactivity_timeout: float = 60
164
+ """::
165
+
166
+ timeout = app.inactivity_timeout
167
+ app.inactivity_timeout = 300
168
+
169
+ Maximum amount of time in seconds the standard output of the program
170
+ can be inactive before the process gets killed, defaults to ``60``.
171
+ Until the response starts, the connection of the client waits for it
172
+ without the inactivity timeout of the server, so that this one gives
173
+ the response ``502`` to a program that takes longer.
174
+ """
175
+
176
+ self.nph: bool | None = None
177
+ """::
178
+
179
+ boolean = app.nph
180
+ app.nph = True
181
+
182
+ The program writes the start line of an HTTP response, in non-parsed
183
+ header mode, as :mod:`Pyjo.Server.CGI` does with its attribute ``nph``,
184
+ which takes the place of the header ``Status``, true by default for a
185
+ script whose file name starts with ``nph-``, as web servers have it.
186
+ """
187
+
188
+ self.script: str | None = None
189
+ """::
190
+
191
+ script = app.script
192
+ app.script = '/usr/lib/cgi-bin/hello.cgi'
193
+ app.script = 'index.php'
194
+
195
+ Script of the application for every path, with the path after the
196
+ mount as ``PATH_INFO``, or for every path that names no script of
197
+ :attr:`document_root`, in which it is then.
198
+ """
199
+
200
+ self.script_extensions: list[str] | None = None
201
+ """::
202
+
203
+ extensions = app.script_extensions
204
+ app.script_extensions = ['.php']
205
+
206
+ Extensions of the names of the scripts of :attr:`document_root`, every
207
+ file of which is a script without them, as cgi-bin.
208
+ """
209
+
210
+ self.sendfile: Sendfile | None = None
211
+ """::
212
+
213
+ places = app.sendfile
214
+ app.sendfile = '/var/www/private'
215
+ app.sendfile = ['/var/www/private', '/var/www/media']
216
+ app.sendfile = re.compile(r'/var/www/[^/]+/private/.*')
217
+
218
+ Directories of the files that the program may reply with by the header
219
+ ``X-Sendfile``, as Apache and lighttpd take it, with the path of the
220
+ file, absolute or in one of them, or regular expressions of the whole
221
+ path of such a file, once its symbolic links are resolved, as the
222
+ private files that are not in :attr:`document_root`, none by default,
223
+ which passes the header on as it is. A route that embeds the
224
+ application replies then with the file, as the helper ``reply.file`` of
225
+ :mod:`Pyjoyment` does, with the status and the other headers of the
226
+ program, and a file that they do not allow is not found.
227
+ """
228
+
229
+ self.static: bool = False
230
+ """::
231
+
232
+ boolean = app.static
233
+ app.static = True
234
+
235
+ Reply with a file of :attr:`document_root` for a path that names it and
236
+ no script of :attr:`script_extensions`, as ``try_files`` of nginx does,
237
+ as the helper ``reply.file`` of :mod:`Pyjoyment` does, with ranges and
238
+ conditional requests, false by default. A route that embeds the
239
+ application replies with them, as :mod:`Pyjoyment.Plugin.Mount` makes.
240
+ """
241
+
242
+ super().__init__(attrs, **kwargs)
243
+
244
+ def handler(self, tx: Any) -> None:
245
+ """::
246
+
247
+ app.handler(Pyjo.Transaction.HTTP.new())
248
+ app.handler(Pyjoyment.Controller.new())
249
+
250
+ Run the program for the request of the transaction, or of the
251
+ controller of a route that embeds the application, and write its
252
+ response.
253
+ """
254
+ if self.script is None and self.document_root is None and 'SCRIPT_FILENAME' not in self.env:
255
+ raise Pyjo.Exception.new('No CGI application')
256
+ config = {
257
+ 'extensions': self.script_extensions,
258
+ 'inactivity': self.inactivity_timeout,
259
+ 'root': os.fspath(self.document_root) if self.document_root is not None else None,
260
+ 'script': self.script,
261
+ 'sendfile': _places(self.sendfile),
262
+ 'static': self.static,
263
+ }
264
+ _Call.run(self, config, tx)
265
+
266
+
267
+ # A request to an application, whose response of CGI is written as it comes, which a subclass sends and reads
268
+ class _Response:
269
+ name = 'CGI'
270
+
271
+ # The path of the mount itself, which a server takes for the whole path in SCRIPT_NAME without PATH_INFO
272
+ mount_path = ''
273
+
274
+ def __init__(self, app: Any, config: dict[str, Any], tx: Any, c: Any) -> None:
275
+ self.app = app
276
+ self.extensions: list[str] | None = config['extensions']
277
+ self.inactivity: float = config['inactivity']
278
+ self.root: str | None = config['root']
279
+ self.script: str | None = config['script']
280
+ self.sendfile: list[str | re.Pattern[str]] | None = config['sendfile']
281
+ self.static: bool = config['static']
282
+ self.waiting: tuple[str, float] | None = None
283
+ self.c = c
284
+ self.tx = tx
285
+ self.aborted = False
286
+ self.chunked = False
287
+ self.closed = False
288
+ self.done = False
289
+ self.head = bytearray()
290
+ self.headers: list[tuple[str, str]] | None = None
291
+ self.nph = False
292
+ self.offset = 0
293
+ self.parser: Any = None
294
+ self.pending: list[bytes] = []
295
+ self.sent = 0
296
+ self.started = False
297
+ self.stream: Any = None
298
+
299
+ # The chunks that wait to be written to the client, after which its connection closes when it is closing
300
+ self.writing = 0
301
+ self.closing = False
302
+
303
+ # The request of the transaction, or of the controller of a route that embeds the application
304
+ @classmethod
305
+ def run(cls, app: Any, config: dict[str, Any], tx: Any, *args: Any) -> None:
306
+ c = None
307
+ if not isinstance(tx, Pyjo.Transaction.object):
308
+ c = tx
309
+ tx = c.tx
310
+ if not Pyjo.App._within(c): # pyright: ignore[reportPrivateUsage]
311
+ return c.helpers.reply.not_found()
312
+ c.render_later()
313
+ cls(app, config, tx, c, *args).start()
314
+
315
+ def start(self) -> None:
316
+ self.tx.on('finish', self._finish)
317
+ params = {**Pyjo.App._cgi(self.tx, self.c), **_extra(self.tx)} # pyright: ignore[reportPrivateUsage]
318
+
319
+ # Not the header Proxy, whose HTTP_PROXY an application may take for the proxy of its own requests, as httpoxy
320
+ # (CVE-2016-5385) does, as nginx and Apache leave it out
321
+ params.pop('HTTP_PROXY', None)
322
+
323
+ # A file of the directory of the scripts, as try_files of nginx serves it
324
+ root, extensions = self.root, self.extensions
325
+ if (c := self.c) is not None and self.static and root is not None and extensions is not None:
326
+ if (file := _file(root, params['PATH_INFO'], extensions)) is not None:
327
+ self.done = True
328
+ return c.helpers.reply.file(file)
329
+
330
+ env = self.app.env
331
+
332
+ # The script of the path in the directory of the scripts, or the one script for a path that names none
333
+ if root is not None and 'SCRIPT_FILENAME' not in env:
334
+ if not self._script(params, root):
335
+ return self._not_found()
336
+
337
+ else:
338
+ # The one script for every path
339
+ if (script := self.script) is not None and 'SCRIPT_FILENAME' not in env:
340
+ params['SCRIPT_FILENAME'] = _chars(script)
341
+ if not params['PATH_INFO'] and params['SCRIPT_NAME']:
342
+ params['PATH_INFO'] = self.mount_path
343
+ params.update(self._defaults())
344
+ params.update({name: _chars(value) for name, value in env.items()})
345
+
346
+ # The connection of the client waits for the response without its inactivity timeout, which the one of the
347
+ # application takes the place of, so that an application that takes longer gives 502
348
+ if (connection := self.tx.connection) is not None and (stream := Pyjo.IOLoop.stream(connection)) is not None:
349
+ self.waiting = (connection, stream.timeout)
350
+ stream.timeout = 0
351
+
352
+ self._open(params)
353
+
354
+ # The connection of the client, once what has been written to it is
355
+ def _close(self) -> None:
356
+ if (connection := self.tx.connection) is not None and (stream := Pyjo.IOLoop.stream(connection)) is not None:
357
+ stream.close_gracefully()
358
+
359
+ # The variables of the environment of the application before the ones of the attribute env
360
+ def _defaults(self) -> dict[str, str]:
361
+ return {}
362
+
363
+ # End of the response
364
+ def _end(self) -> None:
365
+ self.done = True
366
+ content = self.tx.res.content
367
+ (content.write_chunk if self.chunked else content.write)(b'')
368
+ self._rendered()
369
+ self._stop()
370
+
371
+ def _failed(self, message: str) -> None:
372
+ if self.done:
373
+ return
374
+ self.aborted = True
375
+
376
+ # The response has started, so the connection is closed, as the response cannot change, once the chunks that
377
+ # wait have been written
378
+ if self.started:
379
+ self.done = True
380
+ if not self.closed:
381
+ self.app.log.error(message)
382
+ if self.writing:
383
+ self.closing = True
384
+ else:
385
+ self._close()
386
+ return self._stop()
387
+
388
+ self.done = True
389
+ self._stop()
390
+ self._waited()
391
+ self.app.log.error(message)
392
+ res = self.tx.res
393
+ res.code = 502
394
+ res.headers.content_type = 'text/plain'
395
+ res.body = b'Bad Gateway'
396
+ self._rendered()
397
+
398
+ # The end of the transaction, also when the connection of the client is gone before the end of the response
399
+ def _finish(self, *args: Any) -> None:
400
+ self.closed = True
401
+ if not self.done:
402
+ self.aborted = True
403
+ self._stop()
404
+
405
+ # The response of CGI of the output that has come
406
+ def _flush(self, ended: bool) -> None:
407
+ if self.headers is None:
408
+ if ended:
409
+ self._failed(f'{self.name} application ended without a response')
410
+ return
411
+
412
+ # Whole response
413
+ res = self.tx.res
414
+ if not self.started:
415
+ self.started = True
416
+ self._waited()
417
+ if self._sendfile():
418
+ return
419
+ _status(res, self.headers)
420
+ if ended:
421
+ self.done = True
422
+ res.body = b''.join(self.pending)
423
+ self.pending = []
424
+ self._rendered()
425
+ return self._stop()
426
+
427
+ # Response as it comes, with the headers only for a HEAD request
428
+ self.chunked = res.headers.content_length is None and self.tx.req.version == '1.1'
429
+ if self.tx.is_empty:
430
+ return self._end()
431
+
432
+ if len(self.pending):
433
+ chunk, self.pending = b''.join(self.pending), []
434
+ content = res.content
435
+ stream = self.stream
436
+
437
+ # The next output once the chunk has been written to the client
438
+ def drained(*args: Any) -> None:
439
+ self.writing -= 1
440
+ if self.closing and not self.writing:
441
+ return self._close()
442
+ if (stream := self.stream) is not None and not self.done:
443
+ stream.start()
444
+
445
+ if stream is not None and not ended:
446
+ stream.stop()
447
+ self.sent += len(chunk)
448
+ self.writing += 1
449
+ (content.write_chunk if self.chunked else content.write)(chunk, drained)
450
+ self._rendered()
451
+ if ended:
452
+ self._end()
453
+
454
+ def _not_found(self) -> None:
455
+ self.done = True
456
+ if (c := self.c) is not None:
457
+ return c.helpers.reply.not_found()
458
+ res = self.tx.res
459
+ res.code = 404
460
+ res.headers.content_type = 'text/plain'
461
+ res.body = b'Not Found'
462
+ self.tx.resume()
463
+
464
+ # The request to the application, with its parameters
465
+ def _open(self, params: dict[str, str]) -> None:
466
+ raise NotImplementedError('Method "_open" not implemented by subclass')
467
+
468
+ def _rendered(self) -> None:
469
+ if (c := self.c) is not None:
470
+ c.rendered()
471
+ else:
472
+ self.tx.resume()
473
+
474
+ # The script of the path in the directory of the scripts, as nginx has it with fastcgi_split_path_info and
475
+ # $document_root$fastcgi_script_name, or the one script for a path that names none
476
+ def _script(self, params: dict[str, str], root: str) -> bool:
477
+ extensions = self.extensions or []
478
+ if (script := self.script) is not None and not _names_script(params['PATH_INFO'], extensions):
479
+ params['PATH_INFO'] = f'/{script.lstrip("/")}'
480
+ return _script(params, root, extensions)
481
+
482
+ # The file of the header X-Sendfile of the application, which sendfile allows
483
+ def _sendfile(self) -> bool:
484
+ if (c := self.c) is None or not (places := self.sendfile) or (headers := self.headers) is None:
485
+ return False
486
+ if (value := next((value for name, value in headers if name.lower() == 'x-sendfile'), None)) is None:
487
+ return False
488
+ self.done = True
489
+ self._stop()
490
+ if (file := _allowed(places, value)) is None:
491
+ self.app.log.error(f'{self.name} application sent the file "{value}", which sendfile does not allow')
492
+ c.helpers.reply.not_found()
493
+ return True
494
+ _status(self.tx.res, [(name, value) for name, value in headers if name.lower() not in _SENT])
495
+ c.helpers.reply.file(file)
496
+ return True
497
+
498
+ # The body of a response of non-parsed header mode, without its transfer encoding
499
+ def _body(self, content: Any, chunk: bytes) -> None:
500
+ if len(chunk):
501
+ self.pending.append(chunk)
502
+
503
+ # The HTTP response of non-parsed header mode, whose status takes the place of the header Status, and whose body is
504
+ # the one of its length, of its chunks, or up to the end of the output
505
+ def _nph(self, chunk: bytes) -> None:
506
+ if (parser := self.parser) is None:
507
+ self.parser = parser = Pyjo.Message.Response.new()
508
+ parser.content.unsubscribe('read').on('read', self._body)
509
+ if self.tx.is_empty:
510
+ parser.content.skip_body = True
511
+ parser.parse(chunk)
512
+ if (error := parser.error) is not None:
513
+ return self._failed(f'{self.name} application sent an invalid response: {error["message"]}')
514
+ if self.headers is None and (parser.content.is_parsing_body or parser.is_finished):
515
+ headers = parser.headers
516
+ self.headers = [('Status', f'{parser.code} {parser.message or ""}'.rstrip())] + [
517
+ (name, value) for name in headers.names() for value in headers.every_header(name)
518
+ ]
519
+
520
+ # The headers of the response of CGI, and the start of its body
521
+ def _stdout(self, content: bytes) -> None:
522
+ if self.nph:
523
+ return self._nph(content)
524
+ if self.headers is not None:
525
+ if len(content):
526
+ self.pending.append(content)
527
+ return
528
+ self.head += content
529
+ if (m := re.search(rb'\r?\n\r?\n', self.head)) is None:
530
+ return
531
+ head, body = bytes(self.head[: m.start()]), bytes(self.head[m.end() :])
532
+ self.head = bytearray()
533
+ lines = re.split(r'\r?\n', head.decode('latin-1'))
534
+ self.headers = [
535
+ (name.strip(), value.strip()) for name, _, value in (line.partition(':') for line in lines) if name.strip()
536
+ ]
537
+ if len(body):
538
+ self.pending.append(body)
539
+
540
+ # The end of the request to the application
541
+ def _stop(self) -> None:
542
+ raise NotImplementedError('Method "_stop" not implemented by subclass')
543
+
544
+ # The inactivity timeout of the connection of the client again, once the response starts
545
+ def _waited(self) -> None:
546
+ if (waiting := self.waiting) is not None:
547
+ self.waiting = None
548
+ connection, timeout = waiting
549
+ if (stream := Pyjo.IOLoop.stream(connection)) is not None:
550
+ stream.timeout = timeout
551
+
552
+
553
+ # A request to a program, in a process of its own
554
+ class _Call(_Response):
555
+ def __init__(self, app: Pyjo_App_CGI, config: dict[str, Any], tx: Any, c: Any) -> None:
556
+ super().__init__(app, config, tx, c)
557
+ self.child: Pyjo.IOLoop.ChildProcess.object | None = None
558
+ self.command = app.command
559
+
560
+ def _closed(self, stream: Any) -> None:
561
+ self.stream = None
562
+ if self.done or self.closed:
563
+ return
564
+ self._flush(True)
565
+
566
+ def _defaults(self) -> dict[str, str]:
567
+ defaults = {'PATH': path} if (path := os.environ.get('PATH')) is not None else {}
568
+ if self.command is not None:
569
+ defaults['REDIRECT_STATUS'] = '200'
570
+ return defaults
571
+
572
+ def _error(self, stream: Any, err: Any) -> None:
573
+ self._failed(f'CGI application failed: {err}')
574
+
575
+ # The standard error of the program
576
+ def _log(self, stream: Any, chunk: bytes) -> None:
577
+ if message := chunk.decode('utf-8', 'replace').rstrip():
578
+ self.app.log.warn(f'CGI application: {message}')
579
+
580
+ # The program, or the command that runs the script, in the directory of its file
581
+ def _open(self, params: dict[str, str]) -> None:
582
+ file = params['SCRIPT_FILENAME'].encode('latin-1')
583
+ nph = self.app.nph
584
+ self.nph = os.path.basename(file).startswith(b'nph-') if nph is None else nph
585
+ env = {name.encode('latin-1'): value.encode('latin-1') for name, value in params.items()}
586
+ self.child = child = Pyjo.IOLoop.spawn(
587
+ [*(self.command or []), file], cwd=os.path.dirname(file) or None, detached=True, env=env
588
+ )
589
+ child.on('error', self._unstarted)
590
+ if (stream := child.stdout) is None or (errors := child.stderr) is None:
591
+ return
592
+
593
+ # The standard output, the response, and the standard error, which is logged until its end
594
+ self.stream = stream
595
+ stream.timeout = self.inactivity
596
+ stream.on('read', self._read)
597
+ stream.on('close', self._closed)
598
+ stream.on('error', self._error)
599
+ stream.on('timeout', self._timeout)
600
+ errors.on('read', self._log)
601
+ self._write()
602
+
603
+ def _read(self, stream: Any, chunk: bytes) -> None:
604
+ if self.done:
605
+ return
606
+ self._stdout(chunk)
607
+ if not self.done:
608
+ self._flush(False)
609
+
610
+ # The program of the first parts of the path that are a file of the directory, as cgi-bin, or of the path up to its
611
+ # first part with one of the extensions, which is a file of the directory, as the command runs no other file
612
+ def _script(self, params: dict[str, str], root: str) -> bool:
613
+ if (extensions := self.extensions) is not None:
614
+ if not super()._script(params, root):
615
+ return False
616
+ return _is_script(params['SCRIPT_FILENAME'], extensions) and _program(params, root)
617
+ for path in (params['PATH_INFO'], f'/{self.script.lstrip("/")}' if self.script is not None else None):
618
+ if path is None:
619
+ continue
620
+ parts = (path or '/').split('/')
621
+ if '..' in parts:
622
+ return False
623
+ for end in range(2, len(parts) + 1):
624
+ if _inside(root, os.fsdecode('/'.join(parts[1:end]).encode('latin-1'))) is not None:
625
+ _split(params, root, parts, end)
626
+ return True
627
+ return False
628
+
629
+ # The end of the request, with the process of a response that has failed, or of a client that is gone, which the
630
+ # end of its output does not end
631
+ def _stop(self) -> None:
632
+ if (stream := self.stream) is not None:
633
+ self.stream = None
634
+ stream.close()
635
+ if (child := self.child) is not None:
636
+ self.child = None
637
+ if (stdin := child.stdin) is not None:
638
+ stdin.close()
639
+ if self.aborted:
640
+ child.kill(signal.SIGKILL)
641
+
642
+ def _timeout(self, stream: Any) -> None:
643
+ self._failed('CGI application inactive')
644
+
645
+ def _unstarted(self, child: Any, err: Any) -> None:
646
+ self._failed(f"Can't run CGI application: {getattr(err, 'strerror', None) or err}")
647
+
648
+ # The body of the request on the standard input, a chunk once the one before has been written, and its end
649
+ def _write(self, *args: Any) -> None:
650
+ if (child := self.child) is None or (stdin := child.stdin) is None:
651
+ return
652
+ chunk = self.tx.req.content.get_body_chunk(self.offset) or b''
653
+ if not len(chunk):
654
+ return stdin.close_gracefully()
655
+ self.offset += len(chunk)
656
+ stdin.write(chunk, self._write)
657
+
658
+
659
+ # A file that one of the directories has, with an absolute path or one in it, or that a regular expression matches,
660
+ # once its symbolic links are resolved
661
+ def _allowed(places: list[str | re.Pattern[str]], path: str) -> str | None:
662
+ for place in places:
663
+ if isinstance(place, str):
664
+ if (file := _inside(place, path)) is not None:
665
+ return file
666
+ elif os.path.isabs(path) and os.path.isfile(file := os.path.realpath(path)) and place.fullmatch(file):
667
+ return file
668
+ return None
669
+
670
+
671
+ # Characters of the bytes of a string of the system, as the parameters have them
672
+ def _chars(value: str) -> str:
673
+ return os.fsencode(value).decode('latin-1')
674
+
675
+
676
+ # The parameters that CGI servers add, which the applications of PHP need
677
+ def _extra(tx: Any) -> dict[str, str]:
678
+ req = tx.req
679
+ url = req.url
680
+ uri = url.path.to_abs_str()
681
+ if base := url.base.path.to_str().rstrip('/'):
682
+ uri = base + uri
683
+ if query := url.query.to_str():
684
+ uri += f'?{query}'
685
+ extra = {'GATEWAY_INTERFACE': 'CGI/1.1', 'REQUEST_URI': uri, 'SERVER_SOFTWARE': 'Pyjoyment (Python)'}
686
+ if req.is_secure:
687
+ extra['HTTPS'] = 'on'
688
+ return extra
689
+
690
+
691
+ # A file of the directory for a path that names no script and no directory, as try_files of nginx takes it
692
+ def _file(root: str, path: str, extensions: list[str]) -> str | None:
693
+ if not path or path.endswith('/') or _names_script(path, extensions) or '..' in path.split('/'):
694
+ return None
695
+ return _inside(root, os.fsdecode(path.lstrip('/').encode('latin-1')))
696
+
697
+
698
+ # Directories or regular expressions, as a list
699
+ def _places(value: Sendfile | None) -> list[str | re.Pattern[str]] | None:
700
+ if value is None:
701
+ return None
702
+ places = [value] if isinstance(value, (str, os.PathLike, re.Pattern)) else list(value)
703
+ return [place if isinstance(place, re.Pattern) else os.fspath(place) for place in places]
704
+
705
+
706
+ # A file in a directory, also once its symbolic links are resolved, so that no link leads out of it
707
+ def _inside(directory: str, path: str) -> str | None:
708
+ base = os.path.realpath(directory)
709
+ file = os.path.realpath(os.path.join(base, path))
710
+ if os.path.commonpath([base, file]) != base or not os.path.isfile(file):
711
+ return None
712
+ return file
713
+
714
+
715
+ # A program of the directory of the scripts
716
+ def _program(params: dict[str, str], root: str) -> bool:
717
+ return _inside(root, os.fsdecode(params['SCRIPT_FILENAME'].encode('latin-1'))) is not None
718
+
719
+
720
+ def _is_script(part: str, extensions: list[str]) -> bool:
721
+ return any(part.endswith(extension) for extension in extensions)
722
+
723
+
724
+ # A path that names a script, by the extension of one of its parts
725
+ def _names_script(path: str, extensions: list[str]) -> bool:
726
+ return any(_is_script(part, extensions) for part in path.split('/'))
727
+
728
+
729
+ # The script of the path in the directory of the scripts, up to its first part with one of the extensions, unless the
730
+ # path has ..
731
+ def _script(params: dict[str, str], root: str, extensions: list[str]) -> bool:
732
+ parts = (params['PATH_INFO'] or '/').split('/')
733
+ if '..' in parts:
734
+ return False
735
+ end = next((i + 1 for i, part in enumerate(parts) if _is_script(part, extensions)), len(parts))
736
+ _split(params, root, parts, end)
737
+ if end == len(parts) and params['SCRIPT_FILENAME'].endswith('/') and extensions:
738
+ params['SCRIPT_FILENAME'] += f'index{extensions[0]}'
739
+ params['SCRIPT_NAME'] += f'index{extensions[0]}'
740
+ return True
741
+
742
+
743
+ # The script of the first parts of the path, and the rest of the path
744
+ def _split(params: dict[str, str], root: str, parts: list[str], end: int) -> None:
745
+ script, rest = '/'.join(parts[:end]), '/'.join(parts[end:])
746
+ root = _chars(root)
747
+ params['SCRIPT_FILENAME'] = root.rstrip('/') + script
748
+ params['SCRIPT_NAME'] += script
749
+ params['PATH_INFO'] = f'/{rest}' if end < len(parts) else ''
750
+ params['DOCUMENT_ROOT'] = root
751
+
752
+
753
+ # The status of a response of CGI from its header Status, or a redirect for a header Location, and its other headers
754
+ def _status(res: Any, headers: list[tuple[str, str]]) -> None:
755
+ res.code = 200
756
+ location = False
757
+ for name, value in headers:
758
+ if name.lower() == 'status':
759
+ code, _, message = value.partition(' ')
760
+ res.code = int(code)
761
+ res.message = message or None
762
+ continue
763
+ if name.lower() == 'location':
764
+ location = True
765
+ if not wsgiref.util.is_hop_by_hop(name):
766
+ res.headers.add(name, value)
767
+ if location and not any(name.lower() == 'status' for name, _ in headers):
768
+ res.code = 302
769
+
770
+
771
+ new = Pyjo_App_CGI.new
772
+ object = Pyjo_App_CGI
773
+ CGI = Pyjo_App_CGI