Pyjoyment 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- Pyjo/App/ASGI.py +735 -0
- Pyjo/App/CGI.py +773 -0
- Pyjo/App/FastCGI.py +631 -0
- Pyjo/App/Lambda.py +987 -0
- Pyjo/App/WSGI.py +427 -0
- Pyjo/App/__init__.py +409 -0
- Pyjo/Asset/File.py +377 -0
- Pyjo/Asset/Memory.py +235 -0
- Pyjo/Asset/__init__.py +200 -0
- Pyjo/Base.py +215 -0
- Pyjo/Cache.py +119 -0
- Pyjo/Collection.py +570 -0
- Pyjo/Content/MultiPart.py +325 -0
- Pyjo/Content/Single.py +193 -0
- Pyjo/Content/__init__.py +806 -0
- Pyjo/Cookie/Request.py +90 -0
- Pyjo/Cookie/Response.py +294 -0
- Pyjo/Cookie/__init__.py +143 -0
- Pyjo/DOM/CSS.py +1019 -0
- Pyjo/DOM/HTML.py +550 -0
- Pyjo/DOM/__init__.py +1329 -0
- Pyjo/Date.py +294 -0
- Pyjo/EventEmitter.py +522 -0
- Pyjo/Exception.py +494 -0
- Pyjo/File.py +959 -0
- Pyjo/GetOpt.py +605 -0
- Pyjo/Headers.py +760 -0
- Pyjo/HelloWorld.py +77 -0
- Pyjo/Home.py +109 -0
- Pyjo/IOLoop/ChildProcess.py +591 -0
- Pyjo/IOLoop/Client.py +736 -0
- Pyjo/IOLoop/Datagram.py +982 -0
- Pyjo/IOLoop/Messages.py +664 -0
- Pyjo/IOLoop/Server.py +458 -0
- Pyjo/IOLoop/Stdio.py +446 -0
- Pyjo/IOLoop/Stream.py +644 -0
- Pyjo/IOLoop/Subprocess.py +419 -0
- Pyjo/IOLoop/TLS.py +360 -0
- Pyjo/IOLoop/__init__.py +1036 -0
- Pyjo/IOLoop/certs/README.md +41 -0
- Pyjo/IOLoop/certs/ca.crt +20 -0
- Pyjo/IOLoop/certs/server.crt +21 -0
- Pyjo/IOLoop/certs/server.key +28 -0
- Pyjo/JSON/Pointer.py +135 -0
- Pyjo/JSON/RPC.py +772 -0
- Pyjo/JSON/Schema.py +2506 -0
- Pyjo/JSON/__init__.py +573 -0
- Pyjo/Loader.py +342 -0
- Pyjo/Log.py +723 -0
- Pyjo/Message/Request.py +725 -0
- Pyjo/Message/Response.py +476 -0
- Pyjo/Message/__init__.py +830 -0
- Pyjo/Parameters.py +472 -0
- Pyjo/Path.py +419 -0
- Pyjo/Promise.py +894 -0
- Pyjo/Prove.py +1305 -0
- Pyjo/Reactor/Asyncio.py +854 -0
- Pyjo/Reactor/Poll.py +439 -0
- Pyjo/Reactor/__init__.py +525 -0
- Pyjo/SSE.py +119 -0
- Pyjo/Server/ASGI.py +486 -0
- Pyjo/Server/CGI.py +259 -0
- Pyjo/Server/Daemon.py +1007 -0
- Pyjo/Server/FastCGI.py +517 -0
- Pyjo/Server/Hot/__init__.py +687 -0
- Pyjo/Server/Hot/__main__.py +5 -0
- Pyjo/Server/Lambda.py +641 -0
- Pyjo/Server/Prefork.py +917 -0
- Pyjo/Server/Prethread.py +394 -0
- Pyjo/Server/Reload/Backend/Poll.py +114 -0
- Pyjo/Server/Reload/Backend/__init__.py +126 -0
- Pyjo/Server/Reload/__init__.py +482 -0
- Pyjo/Server/Reload/__main__.py +5 -0
- Pyjo/Server/Stdio.py +228 -0
- Pyjo/Server/WSGI.py +349 -0
- Pyjo/Server/__init__.py +396 -0
- Pyjo/String/Bytes.py +532 -0
- Pyjo/String/HTML.py +210 -0
- Pyjo/String/Mixin.py +333 -0
- Pyjo/String/Template.py +470 -0
- Pyjo/String/Unicode.py +553 -0
- Pyjo/String/__init__.py +0 -0
- Pyjo/TOML.py +232 -0
- Pyjo/Template.py +1290 -0
- Pyjo/Test/Pyjo.py +1454 -0
- Pyjo/Test/__init__.py +1233 -0
- Pyjo/Test/pytest_plugin.py +305 -0
- Pyjo/Transaction/HTTP.py +374 -0
- Pyjo/Transaction/WebSocket.py +709 -0
- Pyjo/Transaction/__init__.py +429 -0
- Pyjo/URL/Template.py +459 -0
- Pyjo/URL/__init__.py +651 -0
- Pyjo/Upload.py +139 -0
- Pyjo/UserAgent/CookieJar.py +391 -0
- Pyjo/UserAgent/Proxy.py +138 -0
- Pyjo/UserAgent/Server.py +239 -0
- Pyjo/UserAgent/Transactor.py +766 -0
- Pyjo/UserAgent/__init__.py +1243 -0
- Pyjo/Util.py +3567 -0
- Pyjo/WebSocket.py +332 -0
- Pyjo/YAML.py +1167 -0
- Pyjo/__init__.py +441 -0
- Pyjo/__main__.py +35 -0
- Pyjo/py.typed +0 -0
- Pyjoyment/Command/Author/__init__.py +20 -0
- Pyjoyment/Command/Author/generate/__init__.py +106 -0
- Pyjoyment/Command/Author/generate/app.py +299 -0
- Pyjoyment/Command/Author/generate/dockerfile.py +281 -0
- Pyjoyment/Command/Author/generate/kubernetes.py +514 -0
- Pyjoyment/Command/Author/generate/lambda_zip.py +372 -0
- Pyjoyment/Command/Author/generate/lite_app.py +113 -0
- Pyjoyment/Command/Author/generate/plugin.py +267 -0
- Pyjoyment/Command/Author/generate/pyproject.py +123 -0
- Pyjoyment/Command/Author/inflate.py +101 -0
- Pyjoyment/Command/__init__.py +332 -0
- Pyjoyment/Command/asgi.py +92 -0
- Pyjoyment/Command/cgi.py +90 -0
- Pyjoyment/Command/daemon.py +160 -0
- Pyjoyment/Command/eval.py +128 -0
- Pyjoyment/Command/fastcgi.py +99 -0
- Pyjoyment/Command/get.py +305 -0
- Pyjoyment/Command/lambda.py +117 -0
- Pyjoyment/Command/prefork.py +181 -0
- Pyjoyment/Command/prethread.py +126 -0
- Pyjoyment/Command/routes.py +133 -0
- Pyjoyment/Command/stdio.py +92 -0
- Pyjoyment/Command/version.py +141 -0
- Pyjoyment/Command/wsgi.py +93 -0
- Pyjoyment/Commands.py +546 -0
- Pyjoyment/Controller.py +1307 -0
- Pyjoyment/HelloWorld.py +56 -0
- Pyjoyment/Lite.py +494 -0
- Pyjoyment/Plugin/AllowedHosts.py +205 -0
- Pyjoyment/Plugin/Config.py +248 -0
- Pyjoyment/Plugin/DefaultHelpers.py +1202 -0
- Pyjoyment/Plugin/EPLRenderer.py +134 -0
- Pyjoyment/Plugin/EPRenderer.py +212 -0
- Pyjoyment/Plugin/HeaderCondition.py +98 -0
- Pyjoyment/Plugin/Health.py +325 -0
- Pyjoyment/Plugin/JSONConfig.py +172 -0
- Pyjoyment/Plugin/Mount.py +223 -0
- Pyjoyment/Plugin/PyRenderer.py +209 -0
- Pyjoyment/Plugin/TOMLConfig.py +141 -0
- Pyjoyment/Plugin/TagHelpers.py +959 -0
- Pyjoyment/Plugin/YAMLConfig.py +178 -0
- Pyjoyment/Plugin/__init__.py +80 -0
- Pyjoyment/Plugins.py +215 -0
- Pyjoyment/Renderer.py +673 -0
- Pyjoyment/Routes/Match.py +251 -0
- Pyjoyment/Routes/Pattern.py +443 -0
- Pyjoyment/Routes/Route.py +827 -0
- Pyjoyment/Routes/__init__.py +419 -0
- Pyjoyment/Sessions.py +255 -0
- Pyjoyment/Static.py +421 -0
- Pyjoyment/Types.py +238 -0
- Pyjoyment/Validator/Validation.py +395 -0
- Pyjoyment/Validator/__init__.py +304 -0
- Pyjoyment/__init__.py +1083 -0
- Pyjoyment/__main__.py +5 -0
- Pyjoyment/py.typed +0 -0
- Pyjoyment/resources/public/favicon.ico +0 -0
- Pyjoyment/resources/public/favicon.svg +345 -0
- Pyjoyment/resources/public/pyjo/bootstrap/LICENSE +21 -0
- Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.bundle.min.js.map +1 -0
- Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.css +6 -0
- Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.js +7 -0
- Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.min.css.map +1 -0
- Pyjoyment/resources/public/pyjo/failunicat.png +0 -0
- Pyjoyment/resources/public/pyjo/failunicat.svg +50 -0
- Pyjoyment/resources/public/pyjo/highlight.js/LICENSE +29 -0
- Pyjoyment/resources/public/pyjo/highlight.js/highlight-mojo-dark.css +56 -0
- Pyjoyment/resources/public/pyjo/highlight.js/highlight.min.js +1262 -0
- Pyjoyment/resources/public/pyjo/highlight.js/pyjoyment.min.js +1 -0
- Pyjoyment/resources/public/pyjo/logo-black-4x.png +0 -0
- Pyjoyment/resources/public/pyjo/logo-black-4x.svg +346 -0
- Pyjoyment/resources/public/pyjo/logo-white-2x.png +0 -0
- Pyjoyment/resources/public/pyjo/logo-white-2x.svg +346 -0
- Pyjoyment/resources/public/pyjo/logo-white-4x.png +0 -0
- Pyjoyment/resources/public/pyjo/logo-white-4x.svg +346 -0
- Pyjoyment/resources/public/pyjo/logo-white.png +0 -0
- Pyjoyment/resources/public/pyjo/logo-white.svg +346 -0
- Pyjoyment/resources/public/pyjo/logo.png +0 -0
- Pyjoyment/resources/public/pyjo/logo.svg +346 -0
- Pyjoyment/resources/public/pyjo/notfound.png +0 -0
- Pyjoyment/resources/public/pyjo/notfound.svg +3 -0
- Pyjoyment/resources/public/pyjo/nounicat.png +0 -0
- Pyjoyment/resources/public/pyjo/nounicat.svg +5 -0
- Pyjoyment/resources/public/pyjo/pinstripe-dark.png +0 -0
- Pyjoyment/resources/public/pyjo/pinstripe-dark.svg +4 -0
- Pyjoyment/resources/public/pyjo/pinstripe-light.png +0 -0
- Pyjoyment/resources/public/pyjo/pinstripe-light.svg +4 -0
- Pyjoyment/resources/public/pyjo/pyjo.css +204 -0
- Pyjoyment/resources/templates/pyjo/debug.html.ep +346 -0
- Pyjoyment/resources/templates/pyjo/exception.html.ep +32 -0
- Pyjoyment/resources/templates/pyjo/not_found.html.ep +45 -0
- pyjoyment-0.1.0.dist-info/METADATA +376 -0
- pyjoyment-0.1.0.dist-info/RECORD +200 -0
- pyjoyment-0.1.0.dist-info/WHEEL +4 -0
- pyjoyment-0.1.0.dist-info/entry_points.txt +8 -0
- pyjoyment-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -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
|
Pyjo/Transaction/HTTP.py
ADDED
|
@@ -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
|