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,305 @@
1
+ """
2
+ Pyjo.Test.pytest_plugin - Pyjoyment in pytest
3
+ =============================================
4
+ ::
5
+
6
+ $ pytest
7
+ $ pytest -v t/basic.py
8
+
9
+ # pyproject.toml
10
+ [tool.pytest.ini_options]
11
+ testpaths = ["t", "tests"]
12
+ pythonpath = ["."]
13
+
14
+ # tests/conftest.py
15
+ import pytest
16
+
17
+
18
+ @pytest.fixture
19
+ def app():
20
+ return 'MyApp'
21
+
22
+
23
+ # tests/test_basic.py
24
+ def test_index(t):
25
+ t.get_ok('/').status_is(200).content_like(r'Pyjoyment')
26
+
27
+ :mod:`Pyjo.Test.pytest_plugin` is a plugin of `pytest <https://pytest.org>`_,
28
+ which pytest loads where Pyjoyment is installed. It runs the test scripts of
29
+ :mod:`Pyjo.Test` as :mod:`Pyjo.Prove` does, each as a test of pytest, and lets
30
+ the tests of pytest use :mod:`Pyjo.Test` and :mod:`Pyjo.Test.Pyjo`.
31
+ :mod:`Pyjo.Prove` stays the runner of the test scripts, and pytest runs the
32
+ same scripts for whoever runs ``pytest``, as a test explorer of an IDE or a
33
+ coding agent. Pyjoyment does not depend on pytest, and nothing of it but this
34
+ plugin imports pytest.
35
+
36
+ It has no counterpart in Mojolicious.
37
+
38
+ Test scripts
39
+ ------------
40
+
41
+ A file with the extension ``.py`` in the directories of ``pyjo_prove_paths``,
42
+ ``t`` by default, is a test script, whatever its name, but ``conftest.py``,
43
+ and pytest does not import it. The script runs in a process of its own, in the
44
+ root directory of pytest, with :func:`Pyjo.Prove.run_script`, and is a test of
45
+ pytest of its own, ``t/basic.py``, which passes, fails or is skipped as
46
+ :mod:`Pyjo.Prove` reports the script. The report of a failure shows the
47
+ failed tests, the standard error of the script with their diagnostic
48
+ messages, and what is wrong with the plan or the exit status; the whole TAP
49
+ output of the script is its captured standard output. ``Bail out!`` stops the
50
+ run.
51
+
52
+ pytest runs its tests one after another, and so the test scripts, where
53
+ :mod:`Pyjo.Prove` runs several at the same time with ``-j``; ``-n`` of
54
+ `pytest-xdist <https://pytest-xdist.readthedocs.io>`_ does so, as each test
55
+ script is a test of its own.
56
+
57
+ The scripts get the environment that pytest started with, as
58
+ ``python -m Pyjo.Prove -l`` gives it to them: the root directory in
59
+ ``PYTHONPATH``, ``PYJO_HARNESS_ACTIVE``, and ``PYJO_HARNESS_IS_VERBOSE`` with
60
+ ``-v``.
61
+
62
+ ``pytest`` without arguments runs the directories of ``testpaths``, so a
63
+ project whose tests are test scripts names their directory there, in
64
+ ``pyproject.toml``, as the projects of ``generate app`` and
65
+ ``generate plugin`` do, with pytest in the dependency group ``dev``, which
66
+ ``uv run pytest`` installs: ::
67
+
68
+ [dependency-groups]
69
+ dev = ["pytest>=9", "pytest-xdist>=3"]
70
+
71
+ [tool.pytest.ini_options]
72
+ testpaths = ["t"]
73
+ pythonpath = ["."]
74
+
75
+ Tests of pytest
76
+ ---------------
77
+
78
+ The plugin calls :func:`Pyjo.Test.raise_failures`, so that a failed test of
79
+ :mod:`Pyjo.Test` or of :mod:`Pyjo.Test.Pyjo` raises :exc:`AssertionError`
80
+ with its name and its diagnostic message, ``plan(skip_all=...)`` skips the
81
+ test, and ``Bail out!`` stops the run.
82
+
83
+ A test stops at its first failed test, after which
84
+ :attr:`~Pyjo.Test.Pyjo.Pyjo_Test_Pyjo.success` and
85
+ :meth:`~Pyjo.Test.Pyjo.Pyjo_Test_Pyjo.otherwise` have nothing to do. The user
86
+ agent of :mod:`Pyjo.Test.Pyjo` blocks, so a test is a function, not a
87
+ coroutine. The tests run in one process, where their applications share the
88
+ singleton of :mod:`Pyjo.IOLoop` and the environment, which a test script has
89
+ of its own. The tests go to a directory of their own, as ``tests``, as
90
+ :mod:`Pyjo.Prove` runs every script in ``t``, and ``pythonpath`` of pytest
91
+ puts the root directory in :data:`sys.path`, as ``-l`` of :mod:`Pyjo.Prove`
92
+ does for the test scripts, so that a test finds the application by the name
93
+ of its class.
94
+
95
+ Ini options
96
+ -----------
97
+
98
+ ``pyjo_prove_paths``
99
+ The directories of the test scripts, relative to the configuration file,
100
+ ``t`` of the root directory by default. Without a directory, pytest runs
101
+ no test scripts. ::
102
+
103
+ [tool.pytest.ini_options]
104
+ pyjo_prove_paths = ["t", "xt"]
105
+
106
+ ``pyjo_prove_timeout``
107
+ The most seconds a test script may run, no limit by default. A test script
108
+ that runs longer is killed, with the processes it started, and fails. ::
109
+
110
+ [tool.pytest.ini_options]
111
+ pyjo_prove_timeout = "60"
112
+
113
+ Fixtures
114
+ --------
115
+
116
+ ``t``
117
+ A :mod:`Pyjo.Test.Pyjo` object of the application of the fixture ``app``,
118
+ which the tests define, and which can be anything that
119
+ :meth:`Pyjo.Test.Pyjo.Pyjo_Test_Pyjo.__init__` takes. The end of the
120
+ test closes it. ::
121
+
122
+ @pytest.fixture
123
+ def app():
124
+ return Pyjo.File.curfile().dirname().sibling('myapp.py')
125
+
126
+
127
+ def test_index(t):
128
+ t.get_ok('/').status_is(200)
129
+
130
+ See also
131
+ --------
132
+
133
+ :mod:`Pyjoyment`, https://docs.pytest.org.
134
+ """
135
+
136
+ import Pyjo.Prove
137
+ import Pyjo.Test
138
+
139
+ import asyncio
140
+ import os
141
+ import sys
142
+ import unittest
143
+
144
+ from collections.abc import Generator, Iterator
145
+ from pathlib import Path
146
+ from typing import TYPE_CHECKING, Any, cast
147
+
148
+ import pytest
149
+
150
+
151
+ if TYPE_CHECKING:
152
+ import Pyjo.Test.Pyjo
153
+
154
+
155
+ # The most lines of the standard error of a test script in the report of its failure, the first half and the last
156
+ # half, as a script that fails in a loop can write megabytes of it
157
+ STDERR_LINES = 1000
158
+
159
+ _env_key = pytest.StashKey[dict[str, str]]()
160
+
161
+
162
+ def pytest_addoption(parser: pytest.Parser) -> None:
163
+ parser.addini('pyjo_prove_paths', 'the directories of the test scripts of Pyjo.Test', type='paths', default=['t'])
164
+ parser.addini('pyjo_prove_timeout', 'the most seconds a test script of Pyjo.Test may run', default='')
165
+
166
+
167
+ def pytest_configure(config: pytest.Config) -> None:
168
+ # The environment that pytest started with, before a test changes it, as the import of Pyjo.Test.Pyjo sets
169
+ # PYJO_LOG_LEVEL; a test script that runs the harness does not get its options, as with Pyjo.Prove
170
+ env = {name: value for name, value in os.environ.items() if name != 'PYJO_PROVE_OPTS'}
171
+ env['PYTHONPATH'] = os.pathsep.join(['.', *filter(None, [env.get('PYTHONPATH')])])
172
+ env['PYJO_HARNESS_ACTIVE'] = '1'
173
+ if config.get_verbosity() > 0:
174
+ env['PYJO_HARNESS_IS_VERBOSE'] = '1'
175
+ config.stash[_env_key] = env
176
+ Pyjo.Test.raise_failures()
177
+
178
+
179
+ def pytest_unconfigure(config: pytest.Config) -> None:
180
+ Pyjo.Test.raise_failures(False)
181
+
182
+
183
+ # A wrapper, which replaces what the other plugins make of a test script, as the module of pytest for a test_*.py
184
+ @pytest.hookimpl(wrapper=True)
185
+ def pytest_collect_file(
186
+ file_path: Path, parent: pytest.Collector
187
+ ) -> Generator[None, list[pytest.Collector | None], list[pytest.Collector | None]]:
188
+ collectors = yield
189
+ if _is_script(file_path, parent.config):
190
+ # The test of pytest without a collector of the file, as it has one test, whose node is the path alone
191
+ config = parent.config
192
+ try:
193
+ nodeid = file_path.relative_to(config.rootpath).as_posix()
194
+ except ValueError:
195
+ nodeid = file_path.as_posix()
196
+ # pytest leaves the keyword arguments of from_parent without types
197
+ item = ScriptItem.from_parent( # pyright: ignore[reportUnknownMemberType]
198
+ parent, name=file_path.name, path=file_path, nodeid=nodeid
199
+ )
200
+ return [cast(pytest.Collector, item)]
201
+ return collectors
202
+
203
+
204
+ @pytest.hookimpl(wrapper=True)
205
+ def pytest_runtest_makereport(
206
+ item: pytest.Item, call: pytest.CallInfo[None]
207
+ ) -> Generator[None, pytest.TestReport, pytest.TestReport]:
208
+ # The exception of the test, before the plugin of pytest for unittest replaces a unittest.SkipTest
209
+ error = call.excinfo.value if call.excinfo is not None else None
210
+ report = yield
211
+ if isinstance(error, Pyjo.Test.BailOut):
212
+ item.session.shouldstop = f'Bail out! {error}'
213
+
214
+ # A skipped test script or test of Pyjo.Test is reported at its own place, not at the one that raised the skip
215
+ longrepr = report.longrepr
216
+ if report.skipped and isinstance(longrepr, tuple):
217
+ if isinstance(item, ScriptItem) or isinstance(error, unittest.SkipTest):
218
+ path, line = item.reportinfo()[:2]
219
+ # A place without a line, as of a test script, which pytest shows as the path alone
220
+ report.longrepr = cast(Any, (os.fspath(path), line + 1 if line is not None else None, longrepr[2]))
221
+ return report
222
+
223
+
224
+ @pytest.fixture
225
+ def t(app: Any) -> Iterator['Pyjo.Test.Pyjo.Pyjo_Test_Pyjo']:
226
+ # Imported by the first test that uses it, after pytest_configure took the environment
227
+ import Pyjo.Test.Pyjo
228
+
229
+ with Pyjo.Test.Pyjo.new(app) as t:
230
+ yield t
231
+
232
+
233
+ class ScriptItem(pytest.Item):
234
+ """
235
+ The test of pytest that runs a test script.
236
+ """
237
+
238
+ def runtest(self) -> None:
239
+ """::
240
+
241
+ item.runtest()
242
+
243
+ Run the test script with :func:`Pyjo.Prove.run_script`, and fail or
244
+ skip as its result says.
245
+ """
246
+ config = self.config
247
+ path = Path(os.path.relpath(self.path, config.rootpath)).as_posix()
248
+ timeout = str(config.getini('pyjo_prove_timeout')).strip()
249
+ result = asyncio.run(
250
+ Pyjo.Prove.run_script(
251
+ path,
252
+ [sys.executable],
253
+ config.stash[_env_key],
254
+ timeout=float(timeout) if timeout else None,
255
+ capture_stderr=True,
256
+ stderr_lines=STDERR_LINES,
257
+ cwd=str(config.rootpath),
258
+ )
259
+ )
260
+ stderr = list(result.stderr)
261
+ if result.stderr_left_out:
262
+ stderr.insert(STDERR_LINES // 2, f'[{result.stderr_left_out} lines left out]')
263
+
264
+ self.add_report_section('call', 'stdout', '\n'.join(result.output))
265
+
266
+ if result.bail_out is not None:
267
+ self.session.shouldstop = f'Bail out! {result.bail_out}'
268
+ pytest.fail('\n'.join([f'Bail out! {result.bail_out}', *stderr]), pytrace=False)
269
+
270
+ # The first line is the one of the short summary of pytest, the status of Pyjo.Prove
271
+ if result.failed:
272
+ failed = [line for line in result.output if _failed(line)]
273
+ summary = f'Failed {len(result.failed)}/{result.tests} subtests'
274
+ pytest.fail('\n'.join([summary, *failed, *stderr, *result.errors]), pytrace=False)
275
+ if result.errors:
276
+ pytest.fail('\n'.join([*result.errors, *stderr]), pytrace=False)
277
+
278
+ if result.skip_all is not None:
279
+ pytest.skip(result.skip_all or 'no reason given')
280
+
281
+ def reportinfo(self) -> tuple[Path, None, str]:
282
+ """::
283
+
284
+ path, line, name = item.reportinfo()
285
+
286
+ The path of the test script, without a line, and its name in the
287
+ headline of the report of a failure, as ``t/basic.py (TAP)``.
288
+ """
289
+ # Not the node alone: pytest shows the dots of a name that ends the node as ::, of Class.method
290
+ return self.path, None, f'{self.nodeid} (TAP)'
291
+
292
+
293
+ def _failed(line: str) -> bool:
294
+ m = Pyjo.Prove.RE_TEST.match(line.lstrip())
295
+ return m is not None and m.group(1) is not None and (m.group(4) or '').lower() != 'todo'
296
+
297
+
298
+ def _is_script(path: Path, config: pytest.Config) -> bool:
299
+ if path.suffix != '.py' or path.name == 'conftest.py':
300
+ return False
301
+ for directory in config.getini('pyjo_prove_paths'):
302
+ # The default is not resolved by pytest, the paths of the configuration file are
303
+ if path.is_relative_to(directory if isinstance(directory, Path) else config.rootpath / directory):
304
+ return True
305
+ return False
@@ -0,0 +1,374 @@
1
+ r"""
2
+ Pyjo.Transaction.HTTP - HTTP transaction
3
+ ========================================
4
+ ::
5
+
6
+ from Pyjo.Transaction.HTTP import HTTP
7
+
8
+ # Client
9
+ tx = HTTP()
10
+ tx.req.method = 'GET'
11
+ tx.req.url.parse('http://example.com')
12
+ tx.req.headers.accept = 'application/json'
13
+ print(tx.res.code)
14
+ print(tx.res.headers.content_type)
15
+ print(tx.res.body)
16
+ print(tx.remote_address)
17
+
18
+ # Or import the module, as in Google Python Style Guide
19
+
20
+ import Pyjo.Transaction.HTTP
21
+
22
+ # Client
23
+ tx = Pyjo.Transaction.HTTP.new()
24
+ tx.req.method = 'GET'
25
+ tx.req.url.parse('http://example.com')
26
+ tx.req.headers.accept = 'application/json'
27
+ print(tx.res.code)
28
+ print(tx.res.headers.content_type)
29
+ print(tx.res.body)
30
+ print(tx.remote_address)
31
+
32
+ # Server
33
+ tx = Pyjo.Transaction.HTTP.new()
34
+ print(tx.req.method)
35
+ print(tx.req.url.to_abs())
36
+ print(tx.req.headers.accept)
37
+ print(tx.remote_address)
38
+ tx.res.code = 200
39
+ tx.res.headers.content_type = 'text/plain'
40
+ tx.res.body = b'Hello World!'
41
+
42
+ :mod:`Pyjo.Transaction.HTTP` is a container for HTTP transactions, based on
43
+ `RFC 7230 <https://tools.ietf.org/html/rfc7230>`_ and
44
+ `RFC 7231 <https://tools.ietf.org/html/rfc7231>`_.
45
+
46
+ The end of a ``with`` block resumes the transaction with
47
+ :meth:`~Pyjo_Transaction_HTTP.resume`, so that a server writes the response
48
+ that the block prepares. An exception that the block does not catch ends it
49
+ with the default error: the response is replaced with a
50
+ ``500 Internal Server Error``, which is resumed, and the exception goes to
51
+ ``sys.stderr`` as a warning, so that the server writes the response. ::
52
+
53
+ # Server
54
+ with tx:
55
+ tx.res.code = 200
56
+ tx.res.headers.content_type = 'text/plain'
57
+ tx.res.body = b'Hello World!'
58
+
59
+ Events
60
+ ------
61
+
62
+ :mod:`Pyjo.Transaction.HTTP` inherits all events from :mod:`Pyjo.Transaction`
63
+ and can emit the following new ones.
64
+
65
+ request
66
+ ~~~~~~~
67
+ ::
68
+
69
+ tx.on('request', lambda tx: ...)
70
+
71
+ Emitted when a request is ready and needs to be handled. ::
72
+
73
+ tx.on('request', lambda tx: tx.res.headers.header('X-Bender', 'Bite my shiny metal ass!'))
74
+
75
+ resume
76
+ ~~~~~~
77
+ ::
78
+
79
+ tx.on('resume', lambda tx: ...)
80
+
81
+ Emitted when transaction is resumed.
82
+
83
+ unexpected
84
+ ~~~~~~~~~~
85
+ ::
86
+
87
+ tx.on('unexpected', lambda tx, res: ...)
88
+
89
+ Emitted for unexpected ``1xx`` responses that will be ignored. ::
90
+
91
+ tx.on('unexpected', lambda tx, res: tx.res.on('finish', lambda res: print('Follow-up response is finished.')))
92
+
93
+ See also
94
+ --------
95
+
96
+ :mod:`Pyjoyment`, https://mojolicious.org.
97
+
98
+ Ported from `Mojo::Transaction::HTTP <https://github.com/mojolicious/mojo/blob/v9.52/lib/Mojo/Transaction/HTTP.pm>`_
99
+ of Mojolicious 9.52.
100
+ """
101
+
102
+ import Pyjo.Exception
103
+ import Pyjo.Message
104
+ import Pyjo.Message.Response
105
+ import Pyjo.Transaction
106
+
107
+ from Pyjo.Util import warn
108
+
109
+ from types import TracebackType
110
+ from typing import Any, Self
111
+
112
+
113
+ class Pyjo_Transaction_HTTP(Pyjo.Transaction.object):
114
+ """
115
+ :mod:`Pyjo.Transaction.HTTP` inherits all methods from
116
+ :mod:`Pyjo.Transaction` and implements the following new ones.
117
+ """
118
+
119
+ def __init__(self, *, previous: 'Pyjo_Transaction_HTTP | None' = None, **kwargs: Any) -> None:
120
+ """::
121
+
122
+ tx = Pyjo.Transaction.HTTP.new()
123
+ tx = Pyjo.Transaction.HTTP.new(req=Pyjo.Message.Request.new())
124
+
125
+ Construct a new :mod:`Pyjo.Transaction.HTTP` object.
126
+ """
127
+ super().__init__(**kwargs)
128
+
129
+ self.previous = previous
130
+ """::
131
+
132
+ previous = tx.previous
133
+ tx.previous = Pyjo.Transaction.HTTP.new()
134
+
135
+ Previous transaction that triggered this follow-up transaction, usually
136
+ a :mod:`Pyjo.Transaction.HTTP` object. ::
137
+
138
+ # Paths of previous requests
139
+ print(tx.previous.previous.req.url.path)
140
+ print(tx.previous.req.url.path)
141
+ """
142
+
143
+ # The state of the reader and of the writer, as the keys of Mojo's hash
144
+ self._handled = False
145
+ self._http_state: str | None = None
146
+ self._offset = 0
147
+ self._write = 0
148
+ self._writing: bool | None = None
149
+
150
+ def client_read(self, chunk: bytes) -> Self | None:
151
+ """::
152
+
153
+ tx.client_read(data)
154
+
155
+ Read data client-side, used to implement user agents such as
156
+ :mod:`Pyjo.UserAgent`.
157
+ """
158
+ # Skip body for HEAD request
159
+ res = self.res
160
+ if self.req.method.upper() == 'HEAD':
161
+ res.content.skip_body = True
162
+ if not res.parse(chunk).is_finished:
163
+ return None
164
+
165
+ # Unexpected 1xx response
166
+ if not res.is_info or res.headers.upgrade:
167
+ return self.completed()
168
+ self.res = res.new()
169
+ self.emit('unexpected', res)
170
+ if not len(leftovers := res.content.leftovers()):
171
+ return None
172
+ return self.client_read(leftovers)
173
+
174
+ def client_write(self) -> bytes:
175
+ """::
176
+
177
+ data = tx.client_write()
178
+
179
+ Write data client-side, used to implement user agents such as
180
+ :mod:`Pyjo.UserAgent`.
181
+ """
182
+ return self._write_chunk(False)
183
+
184
+ @property
185
+ def is_empty(self) -> bool:
186
+ """::
187
+
188
+ boolean = tx.is_empty
189
+
190
+ Check transaction for ``HEAD`` request and ``1xx``, ``204`` or ``304``
191
+ response.
192
+ """
193
+ return self.req.method.upper() == 'HEAD' or self.res.is_empty
194
+
195
+ @property
196
+ def keep_alive(self) -> bool:
197
+ """::
198
+
199
+ boolean = tx.keep_alive
200
+
201
+ Check if connection can be kept alive.
202
+ """
203
+ # Close
204
+ req, res = self.req, self.res
205
+ req_conn = (req.headers.connection or '').lower()
206
+ res_conn = (res.headers.connection or '').lower()
207
+ if req_conn == 'close' or res_conn == 'close':
208
+ return False
209
+
210
+ # Keep-alive is optional for 1.0
211
+ if res.version == '1.0':
212
+ return res_conn == 'keep-alive'
213
+ if req.version == '1.0':
214
+ return req_conn == 'keep-alive'
215
+
216
+ # Keep-alive is the default for 1.1
217
+ return True
218
+
219
+ def redirects(self) -> list['Pyjo_Transaction_HTTP']:
220
+ """::
221
+
222
+ redirects = tx.redirects()
223
+
224
+ Return a list with all previous transactions that preceded this
225
+ follow-up transaction. ::
226
+
227
+ # Paths of all previous requests
228
+ for previous in tx.redirects():
229
+ print(previous.req.url.path)
230
+ """
231
+ redirects: list[Pyjo_Transaction_HTTP] = []
232
+ previous = self
233
+ while previous := previous.previous:
234
+ redirects.insert(0, previous)
235
+ return redirects
236
+
237
+ def resume(self) -> Self:
238
+ """::
239
+
240
+ tx = tx.resume()
241
+
242
+ Resume transaction.
243
+ """
244
+ self._writing = True
245
+ return self.emit('resume')
246
+
247
+ # Not in Mojolicious: a context manager, which resumes the transaction where the block ends, as a handler of a
248
+ # server ends with a call of resume, with the default error after an exception, which it does not pass on, as the
249
+ # server writes the response after the handler returns
250
+ def __enter__(self) -> Self:
251
+ return self
252
+
253
+ def __exit__(
254
+ self, type: type[BaseException] | None, value: BaseException | None, traceback: TracebackType | None
255
+ ) -> bool:
256
+ error = isinstance(value, Exception)
257
+ if error:
258
+ warn(str(Pyjo.Exception.new(value)))
259
+ res = self.res = Pyjo.Message.Response.new()
260
+ res.code = 500
261
+ res.headers.content_type = 'text/plain;charset=UTF-8'
262
+ res.body = b'Internal Server Error'
263
+ self.resume()
264
+ return error
265
+
266
+ def server_read(self, chunk: bytes) -> None:
267
+ """::
268
+
269
+ tx.server_read(data)
270
+
271
+ Read data server-side, used to implement web servers such as
272
+ :mod:`Pyjo.Server.Daemon`.
273
+ """
274
+ # Parse request
275
+ req = self.req
276
+ if not req.error:
277
+ req.parse(chunk)
278
+
279
+ # Generate response
280
+ if req.is_finished and not self._handled:
281
+ self._handled = True
282
+ self.emit('request')
283
+
284
+ def server_write(self) -> bytes:
285
+ """::
286
+
287
+ data = tx.server_write()
288
+
289
+ Write data server-side, used to implement web servers such as
290
+ :mod:`Pyjo.Server.Daemon`.
291
+ """
292
+ return self._write_chunk(True)
293
+
294
+ def _body(self, msg: Pyjo.Message.object, finish: bool) -> bytes:
295
+ # Prepare body chunk
296
+ buffer = msg.get_body_chunk(self._offset)
297
+ self._offset += len(buffer) if buffer is not None else 0
298
+
299
+ # Delayed
300
+ if buffer is None:
301
+ self._writing = False
302
+
303
+ # Finished (static content does not need an extra round trip for the empty chunk)
304
+ elif not len(buffer) or (not msg.content.is_dynamic and self._offset >= msg.body_size):
305
+ msg.finish()
306
+ if finish:
307
+ self.completed()
308
+ else:
309
+ self._writing = False
310
+
311
+ return buffer if buffer is not None else b''
312
+
313
+ def _headers(self, msg: Pyjo.Message.object, head: bool) -> bytes:
314
+ # Prepare header chunk
315
+ buffer = msg.get_header_chunk(self._offset)
316
+ written = len(buffer)
317
+ self._write -= written
318
+ self._offset += written
319
+
320
+ # Switch to body
321
+ if self._write <= 0:
322
+ self._http_state, self._offset = 'body', 0
323
+
324
+ # Response without body
325
+ if head and self.is_empty:
326
+ self.completed()._http_state = 'empty'
327
+
328
+ return buffer
329
+
330
+ def _start_line(self, msg: Pyjo.Message.object) -> bytes:
331
+ # Prepare start-line chunk
332
+ buffer = msg.get_start_line_chunk(self._offset)
333
+ written = len(buffer)
334
+ self._write -= written
335
+ self._offset += written
336
+
337
+ # Switch to headers
338
+ if self._write <= 0:
339
+ self._http_state, self._write, self._offset = 'headers', msg.header_size, 0
340
+
341
+ return buffer
342
+
343
+ # Renamed from Mojolicious: _write, the attribute _write is the size left to write
344
+ def _write_chunk(self, server: bool) -> bytes:
345
+ # Client starts writing right away
346
+ if self._writing is None and not server:
347
+ self._writing = True
348
+ if not self._writing:
349
+ return b''
350
+
351
+ # Nothing written yet
352
+ msg = self.res if server else self.req
353
+ if not self._http_state:
354
+ self._http_state, self._write = 'start_line', msg.start_line_size
355
+
356
+ # Start-line
357
+ chunk = b''
358
+ if self._http_state == 'start_line':
359
+ chunk += self._start_line(msg)
360
+
361
+ # Headers
362
+ if self._http_state == 'headers':
363
+ chunk += self._headers(msg, server)
364
+
365
+ # Body
366
+ if self._http_state == 'body':
367
+ chunk += self._body(msg, server)
368
+
369
+ return chunk
370
+
371
+
372
+ new = Pyjo_Transaction_HTTP.new
373
+ object = Pyjo_Transaction_HTTP
374
+ HTTP = Pyjo_Transaction_HTTP