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
@@ -0,0 +1,766 @@
1
+ r"""
2
+ Pyjo.UserAgent.Transactor - User agent transactor
3
+ =================================================
4
+ ::
5
+
6
+ from Pyjo.UserAgent.Transactor import Transactor
7
+
8
+ # GET request with Accept header
9
+ t = Transactor()
10
+ print(t.tx('GET', 'http://example.com', {'Accept': '*/*'}).req.to_bytes())
11
+
12
+ # Or import the module, as in Google Python Style Guide
13
+
14
+ import Pyjo.UserAgent.Transactor
15
+
16
+ # GET request with Accept header
17
+ t = Pyjo.UserAgent.Transactor.new()
18
+ print(t.tx('GET', 'http://example.com', {'Accept': '*/*'}).req.to_bytes())
19
+
20
+ # POST request with form-data
21
+ print(t.tx('POST', 'example.com', form={'a': 'b'}).req.to_bytes())
22
+
23
+ # PUT request with JSON data
24
+ print(t.tx('PUT', 'example.com', json={'a': 'b'}).req.to_bytes())
25
+
26
+ :mod:`Pyjo.UserAgent.Transactor` is the transaction building and manipulation
27
+ framework used by :mod:`Pyjo.UserAgent`.
28
+
29
+ Generators
30
+ ----------
31
+
32
+ These content generators are available by default.
33
+
34
+ form
35
+ ~~~~
36
+ ::
37
+
38
+ t.tx('POST', 'http://example.com', form={'a': 'b'})
39
+
40
+ Generate query string, ``application/x-www-form-urlencoded`` or
41
+ ``multipart/form-data`` content. See :meth:`~Pyjo_UserAgent_Transactor.tx`
42
+ for more.
43
+
44
+ json
45
+ ~~~~
46
+ ::
47
+
48
+ t.tx('PATCH', 'http://example.com', json={'a': 'b'})
49
+
50
+ Generate JSON content with :mod:`Pyjo.JSON`. See
51
+ :meth:`~Pyjo_UserAgent_Transactor.tx` for more.
52
+
53
+ multipart
54
+ ~~~~~~~~~
55
+ ::
56
+
57
+ t.tx('PUT', 'http://example.com', multipart=['Hello', 'World!'])
58
+
59
+ Generate multipart content. See :meth:`~Pyjo_UserAgent_Transactor.tx` for
60
+ more.
61
+
62
+ See also
63
+ --------
64
+
65
+ :mod:`Pyjoyment`, https://mojolicious.org.
66
+
67
+ Ported from `Mojo::UserAgent::Transactor <https://github.com/mojolicious/mojo/blob/v9.49/lib/Mojo/UserAgent/Transactor.pm>`_
68
+ of Mojolicious 9.49.
69
+
70
+ Classes
71
+ -------
72
+ """
73
+
74
+ import Pyjo.Asset
75
+ import Pyjo.Asset.File
76
+ import Pyjo.Asset.Memory
77
+ import Pyjo.Base
78
+ import Pyjo.Content
79
+ import Pyjo.Content.MultiPart
80
+ import Pyjo.Content.Single
81
+ import Pyjo.File
82
+ import Pyjo.Headers
83
+ import Pyjo.Message.Response
84
+ import Pyjo.Parameters
85
+ import Pyjo.Promise
86
+ import Pyjo.Transaction
87
+ import Pyjo.Transaction.HTTP
88
+ import Pyjo.Transaction.WebSocket
89
+ import Pyjo.URL
90
+
91
+ from Pyjo.JSON import encode_json
92
+ from Pyjo.Util import encode, url_escape
93
+ from Pyjo.WebSocket import challenge, client_handshake
94
+
95
+ import builtins
96
+ import os
97
+ import re
98
+ import weakref
99
+
100
+ from collections.abc import Callable, Mapping, Sequence
101
+ from typing import Any, Self, cast
102
+ from urllib.parse import SplitResult
103
+
104
+
105
+ type Anything = builtins.object
106
+
107
+ # A generator is called with the transactor, the transaction, its data and its options
108
+ type Generator = Callable[..., Anything]
109
+
110
+
111
+ class Pyjo_UserAgent_Transactor(Pyjo.Base.object):
112
+ """
113
+ :mod:`Pyjo.UserAgent.Transactor` inherits all methods from
114
+ :mod:`Pyjo.Base` and implements the following new ones.
115
+ """
116
+
117
+ def __init__(
118
+ self,
119
+ *,
120
+ compressed: bool | None = None,
121
+ generators: dict[str, Generator] | None = None,
122
+ # Unlike Mojolicious: Pyjoyment names itself
123
+ name: str = 'Pyjoyment (Python)',
124
+ ) -> None:
125
+ """::
126
+
127
+ t = Pyjo.UserAgent.Transactor.new()
128
+ t = Pyjo.UserAgent.Transactor.new(compressed=False)
129
+
130
+ Construct a new :mod:`Pyjo.UserAgent.Transactor` object.
131
+ """
132
+ self._compressed: bool | None = compressed
133
+
134
+ self.generators: dict[str, Generator] = (
135
+ generators if generators is not None else {'form': _form, 'json': _json, 'multipart': _multipart}
136
+ )
137
+ """::
138
+
139
+ generators = t.generators
140
+ t.generators = {'foo': lambda t, tx, *args, **options: ...}
141
+
142
+ Registered content generators, by default only ``form``, ``json`` and
143
+ ``multipart`` are already defined.
144
+ """
145
+
146
+ self.name = name
147
+ """::
148
+
149
+ name = t.name
150
+ t.name = 'Pyjoyment'
151
+
152
+ Value for ``User-Agent`` request header of generated transactions,
153
+ defaults to ``Pyjoyment (Python)``.
154
+ """
155
+
156
+ @property
157
+ def compressed(self) -> bool:
158
+ """::
159
+
160
+ boolean = t.compressed
161
+ t.compressed = boolean
162
+
163
+ Try to negotiate compression for the response content and decompress it
164
+ automatically, defaults to the value of the ``PYJO_GZIP`` environment
165
+ variable or true.
166
+ """
167
+ if self._compressed is None:
168
+ self._compressed = os.environ.get('PYJO_GZIP', '1') not in ('', '0')
169
+ return self._compressed
170
+
171
+ @compressed.setter
172
+ def compressed(self, value: bool) -> None:
173
+ self._compressed = value
174
+
175
+ def add_generator(self, name: str, cb: Generator) -> Self:
176
+ """::
177
+
178
+ t = t.add_generator('foo', lambda t, tx, *args, **options: ...)
179
+
180
+ Register a content generator. ::
181
+
182
+ def foo(t, tx, data, **options): ...
183
+
184
+ t.add_generator('foo', foo)
185
+ """
186
+ self.generators[name] = cb
187
+ return self
188
+
189
+ def download(
190
+ self, head: Pyjo.Transaction.HTTP.object, path: str | os.PathLike[str]
191
+ ) -> Pyjo.Transaction.HTTP.object:
192
+ """::
193
+
194
+ tx = t.download(Pyjo.Transaction.HTTP.new(), '/home/sri/test.tar.gz')
195
+
196
+ Build :mod:`Pyjo.Transaction.HTTP` resumable file download request as
197
+ follow-up to a ``HEAD`` request. Note that this method is
198
+ **EXPERIMENTAL** and might change without warning!
199
+ """
200
+ req = head.req
201
+ tx = self.tx('GET', req.url.clone(), req.headers.to_dict())
202
+ res = tx.res
203
+ if error := head.error:
204
+ res.error = error
205
+ return tx
206
+
207
+ headers = head.res.headers
208
+ accept_ranges = 'bytes' in (headers.accept_ranges or '')
209
+ size = int(headers.content_length or 0)
210
+
211
+ current_size = 0
212
+ file = Pyjo.File.path(path)
213
+ if os.path.isfile(file):
214
+ current_size = os.path.getsize(file)
215
+ if not size:
216
+ res.error = {'message': 'Unknown file size'}
217
+ return tx
218
+ if current_size > size:
219
+ res.error = {'message': 'File size mismatch'}
220
+ return tx
221
+ if current_size == size:
222
+ res.error = {'message': 'Download complete'}
223
+ return tx
224
+ if not accept_ranges:
225
+ res.error = {'message': 'Server does not support partial requests'}
226
+ return tx
227
+ tx.req.headers.range = f'bytes={current_size}-{size}'
228
+
229
+ fh = file.open('ab', buffering=0)
230
+
231
+ # Not in Mojolicious: the file is closed with the transaction that is not finished, as Perl closes it with the
232
+ # last reference
233
+ weakref.finalize(tx, fh.close)
234
+
235
+ @res.content.unsubscribe('read').on('read')
236
+ def read(content: Pyjo.Content.object, chunk: bytes) -> None:
237
+ nonlocal current_size
238
+ current_size += len(chunk)
239
+ try:
240
+ if fh.write(chunk) != len(chunk):
241
+ raise OSError('Short write')
242
+ except OSError as e:
243
+ res.error = {'message': f'Can\'t write to file "{os.fspath(path)}": {e.strerror or e}'}
244
+
245
+ @res.on('finish')
246
+ def finish(res: Pyjo.Message.Response.object) -> None:
247
+ # Not in Mojolicious: the file is closed at the end, as PyPy frees it only when its garbage collector runs
248
+ fh.close()
249
+ if current_size < size:
250
+ res.error = {'message': 'Download incomplete'}
251
+
252
+ return tx
253
+
254
+ def endpoint(self, tx: Pyjo.Transaction.object) -> tuple[str, str | None, int]:
255
+ """::
256
+
257
+ proto, host, port = t.endpoint(Pyjo.Transaction.HTTP.new())
258
+
259
+ Actual endpoint for transaction.
260
+ """
261
+ # Basic endpoint
262
+ req = tx.req
263
+ url = req.url
264
+ proto = url.protocol or 'http'
265
+ host = url.ihost
266
+ port = url.port if url.port is not None else (443 if proto == 'https' else 80)
267
+
268
+ # Proxy for normal HTTP requests
269
+ socks = False
270
+ if (proxy := req.proxy) is not None:
271
+ socks = proxy.protocol == 'socks'
272
+ if proto == 'http' and not req.is_handshake and not socks:
273
+ return _proxy(tx, proto, host, port)
274
+
275
+ return proto, host, port
276
+
277
+ def peer(self, tx: Pyjo.Transaction.object) -> tuple[str, str | None, int]:
278
+ """::
279
+
280
+ proto, host, port = t.peer(Pyjo.Transaction.HTTP.new())
281
+
282
+ Actual peer for transaction.
283
+ """
284
+ return _proxy(tx, *self.endpoint(tx))
285
+
286
+ def promisify(self, promise: Pyjo.Promise.object, tx: Pyjo.Transaction.object) -> Pyjo.Promise.object:
287
+ """::
288
+
289
+ t.promisify(Pyjo.Promise.new(), Pyjo.Transaction.HTTP.new())
290
+
291
+ Resolve or reject :mod:`Pyjo.Promise` object with
292
+ :mod:`Pyjo.Transaction.HTTP` object.
293
+ """
294
+ err = tx.error
295
+ if err and not err.get('code'):
296
+ return promise.reject(err['message'])
297
+ if tx.req.is_handshake and not tx.is_websocket:
298
+ return promise.reject('WebSocket handshake failed')
299
+ return promise.resolve(tx)
300
+
301
+ def proxy_connect(self, old: Pyjo.Transaction.object) -> Pyjo.Transaction.HTTP.object | None:
302
+ """::
303
+
304
+ tx = t.proxy_connect(Pyjo.Transaction.HTTP.new())
305
+
306
+ Build :mod:`Pyjo.Transaction.HTTP` proxy ``CONNECT`` request for
307
+ transaction if possible.
308
+ """
309
+ # Already a CONNECT request
310
+ req = old.req
311
+ if req.method.upper() == 'CONNECT':
312
+ return None
313
+
314
+ # No proxy
315
+ if (proxy := req.proxy) is None or not req.via_proxy:
316
+ return None
317
+ if proxy.protocol == 'socks':
318
+ return None
319
+
320
+ # WebSocket and/or HTTPS
321
+ url = req.url
322
+ if not req.is_handshake and url.protocol != 'https':
323
+ return None
324
+
325
+ # CONNECT request (expect a bad response)
326
+ target = url.clone()
327
+ target.userinfo = None
328
+ new = self.tx('CONNECT', target)
329
+ new.req.proxy = proxy
330
+ content = new.res.content
331
+ content.auto_relax = False
332
+ content.headers.connection = 'keep-alive'
333
+
334
+ return new
335
+
336
+ def redirect(self, old: Pyjo.Transaction.HTTP.object) -> Pyjo.Transaction.HTTP.object | None:
337
+ """::
338
+
339
+ tx = t.redirect(Pyjo.Transaction.HTTP.new())
340
+
341
+ Build :mod:`Pyjo.Transaction.HTTP` follow-up request for ``301``,
342
+ ``302``, ``303``, ``307`` or ``308`` redirect response if possible.
343
+ Since ``QUERY`` requests are safe and idempotent, they are never
344
+ redirected as ``GET`` requests and keep their content, except for
345
+ ``303`` responses.
346
+ """
347
+ # Commonly used codes
348
+ res = old.res
349
+ code = res.code or 0
350
+ if code not in (301, 302, 303, 307, 308):
351
+ return None
352
+
353
+ # CONNECT requests cannot be redirected
354
+ req = old.req
355
+ if req.method.upper() == 'CONNECT':
356
+ return None
357
+
358
+ # Fix location without authority and/or scheme
359
+ if not (locations := res.headers.every_header('Location')) or not locations[0]:
360
+ return None
361
+ location = Pyjo.URL.new(locations[0])
362
+ if not location.is_abs:
363
+ location.base = req.url
364
+ location = location.to_abs()
365
+ proto = location.protocol
366
+ if (proto != 'http' and proto != 'https') or not location.host:
367
+ return None
368
+
369
+ # Clone request if necessary (QUERY is safe and idempotent, so it keeps its content like 307 and 308)
370
+ new = Pyjo.Transaction.HTTP.new()
371
+ method = req.method.upper()
372
+ if code == 307 or code == 308 or (method == 'QUERY' and code != 303):
373
+ if (clone := req.clone()) is None:
374
+ return None
375
+ new.req = clone
376
+ else:
377
+ new.req.method = 'GET' if code == 303 or method == 'POST' else method
378
+ new.req.content.headers = headers = req.headers.clone()
379
+ for name in [name for name in headers.names() if re.match(r'content-', name, re.I)]:
380
+ headers.remove(name)
381
+
382
+ content = new.res.content
383
+ if not self.compressed:
384
+ content.auto_decompress = False
385
+ new.req.url = location
386
+ headers = new.req.headers
387
+ for name in ('Authorization', 'Cookie', 'Host', 'Referer'):
388
+ headers.remove(name)
389
+ if res.content.has_subscribers('sse'):
390
+ for cb in res.content.subscribers('sse'):
391
+ content.on('sse', cb)
392
+
393
+ new.previous = old
394
+ return new
395
+
396
+ def tx(
397
+ self,
398
+ method: str,
399
+ url: str | Pyjo.URL.object | SplitResult,
400
+ *args: Mapping[str, Any] | bytes | str,
401
+ **generator: Any,
402
+ ) -> Pyjo.Transaction.HTTP.object:
403
+ r"""::
404
+
405
+ tx = t.tx('GET', 'example.com')
406
+ tx = t.tx('POST', 'http://example.com')
407
+ tx = t.tx('GET', 'http://example.com', {'Accept': '*/*'})
408
+ tx = t.tx('PUT', 'http://example.com', b'Content!')
409
+ tx = t.tx('PUT', 'http://example.com', form={'a': 'b'})
410
+ tx = t.tx('PUT', 'http://example.com', json={'a': 'b'})
411
+ tx = t.tx('PUT', 'https://example.com', multipart=['a', 'b'])
412
+ tx = t.tx('POST', 'example.com', {'Accept': '*/*'}, b'Content!')
413
+ tx = t.tx('PUT', 'example.com', {'Accept': '*/*'}, form={'a': 'b'})
414
+ tx = t.tx('PUT', 'example.com', {'Accept': '*/*'}, json={'a': 'b'})
415
+ tx = t.tx('PUT', 'example.com', {'Accept': '*/*'}, multipart=['a', 'b'])
416
+
417
+ Versatile general purpose :mod:`Pyjo.Transaction.HTTP` transaction
418
+ builder for requests, with support for generators. The URL is also a
419
+ :class:`urllib.parse.SplitResult` of the standard library, and the
420
+ content a ``str``, which is encoded as UTF-8. ::
421
+
422
+ # Generate and inspect custom GET request with DNT header and content
423
+ print(t.tx('GET', 'example.com', {'DNT': 1}, b'Bye!').req.to_bytes())
424
+
425
+ # Stream response content to STDOUT
426
+ tx = t.tx('GET', 'http://example.com')
427
+ tx.res.content.unsubscribe('read').on('read', lambda content, chunk: print(chunk))
428
+
429
+ # PUT request with content streamed from file
430
+ tx = t.tx('PUT', 'http://example.com')
431
+ tx.req.content.asset = Pyjo.Asset.File.new(path='/foo.txt')
432
+
433
+ The ``json`` content generator uses :mod:`Pyjo.JSON` for encoding and
434
+ sets the content type to ``application/json``. ::
435
+
436
+ # POST request with "application/json" content
437
+ tx = t.tx('POST', 'http://example.com', json={'a': 'b', 'c': [1, 2, 3]})
438
+
439
+ The ``form`` content generator will automatically use query parameters
440
+ for ``GET`` and ``HEAD`` requests. ::
441
+
442
+ # GET request with query parameters
443
+ tx = t.tx('GET', 'http://example.com', form={'a': 'b'})
444
+
445
+ For all other request methods the ``application/x-www-form-urlencoded``
446
+ content type is used. ::
447
+
448
+ # POST request with "application/x-www-form-urlencoded" content
449
+ tx = t.tx('POST', 'http://example.com', form={'a': 'b', 'c': 'd'})
450
+
451
+ Parameters may be encoded with the ``charset`` option. ::
452
+
453
+ # PUT request with Shift_JIS encoded form values
454
+ tx = t.tx('PUT', 'example.com', form={'a': 'b'}, charset='Shift_JIS')
455
+
456
+ A list can be used for multiple form values sharing the same name. ::
457
+
458
+ # POST request with form values sharing the same name
459
+ tx = t.tx('POST', 'http://example.com', form={'a': ['b', 'c', 'd']})
460
+
461
+ A dict with a ``content`` or ``file`` value can be used to switch to
462
+ the ``multipart/form-data`` content type for file uploads. ::
463
+
464
+ # POST request with "multipart/form-data" content
465
+ tx = t.tx('POST', 'http://example.com', form={'mytext': {'content': 'lala'}})
466
+
467
+ # POST request with multiple files sharing the same name
468
+ tx = t.tx('POST', 'http://example.com', form={'mytext': [{'content': 'first'}, {'content': 'second'}]})
469
+
470
+ The ``file`` value should contain the path to the file you want to
471
+ upload or an asset object, like :mod:`Pyjo.Asset.File` or
472
+ :mod:`Pyjo.Asset.Memory`. ::
473
+
474
+ # POST request with upload streamed from file
475
+ tx = t.tx('POST', 'http://example.com', form={'mytext': {'file': '/foo.txt'}})
476
+
477
+ # POST request with upload streamed from asset
478
+ asset = Pyjo.Asset.Memory.new().add_chunk(b'lalala')
479
+ tx = t.tx('POST', 'http://example.com', form={'mytext': {'file': asset}})
480
+
481
+ A ``filename`` value will be generated automatically, but can also be
482
+ set manually if necessary. All remaining values in the dict get merged
483
+ into the ``multipart/form-data`` content as headers. ::
484
+
485
+ # POST request with form values and customized upload (filename and header)
486
+ tx = t.tx(
487
+ 'POST',
488
+ 'http://example.com',
489
+ form={
490
+ 'a': 'b',
491
+ 'c': 'd',
492
+ 'mytext': {'content': 'lalala', 'filename': 'foo.txt', 'Content-Type': 'text/plain'},
493
+ },
494
+ )
495
+
496
+ The ``multipart/form-data`` content type can also be enforced by
497
+ setting the ``Content-Type`` header manually. ::
498
+
499
+ # Force "multipart/form-data"
500
+ headers = {'Content-Type': 'multipart/form-data'}
501
+ tx = t.tx('POST', 'example.com', headers, form={'a': 'b'})
502
+
503
+ The ``multipart`` content generator can be used to build custom
504
+ multipart requests and does not set a content type. ::
505
+
506
+ # POST request with multipart content ("foo" and "bar")
507
+ tx = t.tx('POST', 'http://example.com', multipart=['foo', 'bar'])
508
+
509
+ Similar to the ``form`` content generator you can also pass dicts with
510
+ ``content`` or ``file`` values, as well as headers. ::
511
+
512
+ # POST request with multipart content streamed from file
513
+ tx = t.tx('POST', 'http://example.com', multipart=[{'file': '/foo.txt'}])
514
+
515
+ # PUT request with multipart content streamed from asset
516
+ headers = {'Content-Type': 'multipart/custom'}
517
+ asset = Pyjo.Asset.Memory.new().add_chunk(b'lalala')
518
+ tx = t.tx('PUT', 'http://example.com', headers, multipart=[{'file': asset}])
519
+
520
+ # POST request with multipart content and custom headers
521
+ tx = t.tx(
522
+ 'POST',
523
+ 'http://example.com',
524
+ multipart=[
525
+ {'content': 'Hello', 'Content-Type': 'text/plain', 'Content-Language': 'en-US'},
526
+ {'content': 'World!', 'Content-Type': 'text/plain', 'Content-Language': 'en-US'},
527
+ ],
528
+ )
529
+
530
+ Values of the form and of the multipart content that are ``bytes`` are
531
+ sent as they are, and a ``str`` is encoded with the charset, as UTF-8
532
+ without one.
533
+ """
534
+ # Method and URL
535
+ tx = Pyjo.Transaction.HTTP.new()
536
+ req = tx.req
537
+ req.method = method
538
+ if isinstance(url, Pyjo.URL.object):
539
+ req.url = url
540
+ # Not in Mojolicious: a URL of urllib.parse
541
+ elif isinstance(url, SplitResult):
542
+ req.url = Pyjo.URL.new(url)
543
+ else:
544
+ req.url.parse(url if re.search(r'^/|://', url) else f'http://{url}')
545
+
546
+ # Headers (we identify ourselves and accept gzip compression)
547
+ headers = req.headers
548
+ if args and isinstance(args[0], Mapping):
549
+ headers.from_dict(dict(args[0]))
550
+ args = args[1:]
551
+ if not headers.user_agent:
552
+ headers.user_agent = self.name
553
+ if not self.compressed:
554
+ tx.res.content.auto_decompress = False
555
+ elif not headers.accept_encoding:
556
+ headers.accept_encoding = 'gzip'
557
+
558
+ # Generator
559
+ if generator:
560
+ if not (names := [name for name in generator if name in self.generators]):
561
+ raise TypeError(f'Unknown generator: {", ".join(generator)}')
562
+ data = generator.pop(names[0])
563
+ self.generators[names[0]](self, tx, data, **generator)
564
+
565
+ # Body
566
+ elif args:
567
+ body = args[0]
568
+ # Not in Mojolicious: a str is encoded as UTF-8
569
+ req.body = body.encode() if isinstance(body, str) else bytes(cast(bytes, body))
570
+
571
+ return tx
572
+
573
+ def upgrade(self, tx: Pyjo.Transaction.HTTP.object) -> Pyjo.Transaction.WebSocket.object | None:
574
+ """::
575
+
576
+ tx = t.upgrade(Pyjo.Transaction.HTTP.new())
577
+
578
+ Build :mod:`Pyjo.Transaction.WebSocket` follow-up transaction for
579
+ WebSocket handshake if possible.
580
+ """
581
+ code = tx.res.code or 0
582
+ if not tx.req.is_handshake or code != 101:
583
+ return None
584
+ ws = Pyjo.Transaction.WebSocket.new(handshake=tx, masked=True)
585
+ if not challenge(ws):
586
+ return None
587
+ ws.established = True
588
+ return ws
589
+
590
+ def websocket(
591
+ self, url: str | Pyjo.URL.object | SplitResult, *args: Mapping[str, Any] | Sequence[str]
592
+ ) -> Pyjo.Transaction.HTTP.object:
593
+ """::
594
+
595
+ tx = t.websocket('ws://example.com')
596
+ tx = t.websocket('ws://example.com', {'DNT': 1}, ['v1.proto'])
597
+
598
+ Versatile :mod:`Pyjo.Transaction.HTTP` transaction builder for
599
+ WebSocket handshake requests.
600
+ """
601
+ # New WebSocket transaction
602
+ sub: list[str] = []
603
+ if args and isinstance(args[-1], list):
604
+ sub, args = args[-1], args[:-1]
605
+ tx = self.tx('GET', url, *cast(tuple[Mapping[str, Any], ...], args))
606
+ req = tx.req
607
+ if sub:
608
+ req.headers.sec_websocket_protocol = ', '.join(sub)
609
+
610
+ # Handshake protocol
611
+ url = req.url
612
+ proto = url.protocol or ''
613
+ if proto == 'ws':
614
+ url.scheme = 'http'
615
+ elif proto == 'wss':
616
+ url.scheme = 'https'
617
+ elif proto == 'ws+unix':
618
+ url.scheme = 'http+unix'
619
+
620
+ return client_handshake(tx)
621
+
622
+
623
+ def _content(headers: Pyjo.Headers.object, parts: list[Pyjo.Content.object]) -> Pyjo.Content.MultiPart.object:
624
+ return Pyjo.Content.MultiPart.new(headers=headers, parts=parts)
625
+
626
+
627
+ def _form(
628
+ t: Pyjo_UserAgent_Transactor,
629
+ tx: Pyjo.Transaction.HTTP.object,
630
+ form: Mapping[str, Any],
631
+ *,
632
+ charset: str | None = 'UTF-8',
633
+ ) -> Pyjo.Transaction.HTTP.object:
634
+ # Check for uploads and force multipart if necessary
635
+ req = tx.req
636
+ headers = req.headers
637
+ multipart = bool(re.search(r'multipart/form-data', headers.content_type or '', re.I))
638
+ for value in (v for values in form.values() for v in _list(values)):
639
+ if isinstance(value, Mapping):
640
+ multipart = True
641
+ break
642
+
643
+ # Multipart
644
+ if multipart:
645
+ req.content = _content(headers, _form_parts(charset, form))
646
+ _type(headers, 'multipart/form-data')
647
+ return tx
648
+
649
+ # Query parameters or urlencoded
650
+ method = req.method.upper()
651
+ pairs = [item for name in sorted(form) for item in (name, form[name])]
652
+ if method == 'GET' or method == 'HEAD':
653
+ req.url.query.merge(*pairs)
654
+ else:
655
+ params = Pyjo.Parameters.new(*pairs)
656
+ params.charset = charset
657
+ req.body = params.to_str().encode()
658
+ _type(headers, 'application/x-www-form-urlencoded')
659
+
660
+ return tx
661
+
662
+
663
+ def _form_parts(charset: str | None, form: Mapping[str, Any]) -> list[Pyjo.Content.object]:
664
+ parts: list[Pyjo.Content.object] = []
665
+ for name in sorted(form):
666
+ if (values := form[name]) is None:
667
+ continue
668
+ parts.extend(_parts(charset, name, _list(values)))
669
+
670
+ return parts
671
+
672
+
673
+ def _json(
674
+ t: Pyjo_UserAgent_Transactor, tx: Pyjo.Transaction.HTTP.object, data: Anything
675
+ ) -> Pyjo.Transaction.HTTP.object:
676
+ tx.req.body = encode_json(data)
677
+ _type(tx.req.headers, 'application/json')
678
+ return tx
679
+
680
+
681
+ def _list(values: Anything) -> list[Any]:
682
+ return list(cast(list[Any], values)) if isinstance(values, (list, tuple)) else [values]
683
+
684
+
685
+ def _multipart(
686
+ t: Pyjo_UserAgent_Transactor, tx: Pyjo.Transaction.HTTP.object, parts: list[Any]
687
+ ) -> Pyjo.Transaction.HTTP.object:
688
+ req = tx.req
689
+ req.content = _content(req.headers, _parts(None, None, parts))
690
+ return tx
691
+
692
+
693
+ def _parts(charset: str | None, name: str | None, values: list[Any]) -> list[Pyjo.Content.object]:
694
+ parts: list[Pyjo.Content.object] = []
695
+ for value in values:
696
+ part = Pyjo.Content.Single.new()
697
+ parts.append(part)
698
+
699
+ filename: str | None = None
700
+ headers = part.headers
701
+ if isinstance(value, Mapping):
702
+ # Unlike Mojolicious: the values are taken from a copy, where Mojo deletes them from the hash of the caller
703
+ value = dict(cast(Mapping[str, Any], value))
704
+
705
+ # File
706
+ if file := value.pop('file', None):
707
+ if not isinstance(file, Pyjo.Asset.object):
708
+ file = Pyjo.Asset.File.new(path=os.fspath(cast(str, file)))
709
+ part.asset = file
710
+ if isinstance(file, Pyjo.Asset.File.object) and value.get('filename') is None:
711
+ value['filename'] = Pyjo.File.path(file.path).basename()
712
+
713
+ # Memory
714
+ elif (content := value.pop('content', None)) is not None:
715
+ part.asset = Pyjo.Asset.Memory.new().add_chunk(_bytes(content, None))
716
+
717
+ # Filename and headers
718
+ filename = value.pop('filename', None)
719
+ headers.from_dict(value)
720
+ if name is None:
721
+ continue
722
+ filename = url_escape(filename if filename is not None else name, '"')
723
+ if charset:
724
+ filename = encode(charset, filename).decode('latin-1')
725
+
726
+ # Field
727
+ else:
728
+ part.asset = Pyjo.Asset.Memory.new().add_chunk(_bytes(value, charset))
729
+
730
+ # Content-Disposition
731
+ if name is None or headers.content_disposition is not None:
732
+ continue
733
+ name = url_escape(name, '"')
734
+ if charset:
735
+ name = encode(charset, name).decode('latin-1')
736
+ disposition = f'form-data; name="{name}"'
737
+ if filename is not None:
738
+ disposition += f'; filename="{filename}"'
739
+ headers.content_disposition = disposition
740
+
741
+ return parts
742
+
743
+
744
+ # Not in Mojolicious: bytes are taken as they are, and other values are encoded with the charset, as UTF-8 without one
745
+ def _bytes(value: Anything, charset: str | None) -> bytes:
746
+ if isinstance(value, (bytes, bytearray)):
747
+ return bytes(value)
748
+ return encode(charset or 'UTF-8', str(value))
749
+
750
+
751
+ def _proxy(tx: Pyjo.Transaction.object, proto: str, host: str | None, port: int) -> tuple[str, str | None, int]:
752
+ req = tx.req
753
+ if req.via_proxy and (proxy := req.proxy) is not None:
754
+ return proxy.protocol, proxy.ihost, proxy.port if proxy.port is not None else (443 if proto == 'https' else 80)
755
+
756
+ return proto, host, port
757
+
758
+
759
+ def _type(headers: Pyjo.Headers.object, content_type: str) -> None:
760
+ if not headers.content_type:
761
+ headers.content_type = content_type
762
+
763
+
764
+ new = Pyjo_UserAgent_Transactor.new
765
+ object = Pyjo_UserAgent_Transactor
766
+ Transactor = Pyjo_UserAgent_Transactor