Pyjoyment 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- Pyjo/App/ASGI.py +735 -0
- Pyjo/App/CGI.py +773 -0
- Pyjo/App/FastCGI.py +631 -0
- Pyjo/App/Lambda.py +987 -0
- Pyjo/App/WSGI.py +427 -0
- Pyjo/App/__init__.py +409 -0
- Pyjo/Asset/File.py +377 -0
- Pyjo/Asset/Memory.py +235 -0
- Pyjo/Asset/__init__.py +200 -0
- Pyjo/Base.py +215 -0
- Pyjo/Cache.py +119 -0
- Pyjo/Collection.py +570 -0
- Pyjo/Content/MultiPart.py +325 -0
- Pyjo/Content/Single.py +193 -0
- Pyjo/Content/__init__.py +806 -0
- Pyjo/Cookie/Request.py +90 -0
- Pyjo/Cookie/Response.py +294 -0
- Pyjo/Cookie/__init__.py +143 -0
- Pyjo/DOM/CSS.py +1019 -0
- Pyjo/DOM/HTML.py +550 -0
- Pyjo/DOM/__init__.py +1329 -0
- Pyjo/Date.py +294 -0
- Pyjo/EventEmitter.py +522 -0
- Pyjo/Exception.py +494 -0
- Pyjo/File.py +959 -0
- Pyjo/GetOpt.py +605 -0
- Pyjo/Headers.py +760 -0
- Pyjo/HelloWorld.py +77 -0
- Pyjo/Home.py +109 -0
- Pyjo/IOLoop/ChildProcess.py +591 -0
- Pyjo/IOLoop/Client.py +736 -0
- Pyjo/IOLoop/Datagram.py +982 -0
- Pyjo/IOLoop/Messages.py +664 -0
- Pyjo/IOLoop/Server.py +458 -0
- Pyjo/IOLoop/Stdio.py +446 -0
- Pyjo/IOLoop/Stream.py +644 -0
- Pyjo/IOLoop/Subprocess.py +419 -0
- Pyjo/IOLoop/TLS.py +360 -0
- Pyjo/IOLoop/__init__.py +1036 -0
- Pyjo/IOLoop/certs/README.md +41 -0
- Pyjo/IOLoop/certs/ca.crt +20 -0
- Pyjo/IOLoop/certs/server.crt +21 -0
- Pyjo/IOLoop/certs/server.key +28 -0
- Pyjo/JSON/Pointer.py +135 -0
- Pyjo/JSON/RPC.py +772 -0
- Pyjo/JSON/Schema.py +2506 -0
- Pyjo/JSON/__init__.py +573 -0
- Pyjo/Loader.py +342 -0
- Pyjo/Log.py +723 -0
- Pyjo/Message/Request.py +725 -0
- Pyjo/Message/Response.py +476 -0
- Pyjo/Message/__init__.py +830 -0
- Pyjo/Parameters.py +472 -0
- Pyjo/Path.py +419 -0
- Pyjo/Promise.py +894 -0
- Pyjo/Prove.py +1305 -0
- Pyjo/Reactor/Asyncio.py +854 -0
- Pyjo/Reactor/Poll.py +439 -0
- Pyjo/Reactor/__init__.py +525 -0
- Pyjo/SSE.py +119 -0
- Pyjo/Server/ASGI.py +486 -0
- Pyjo/Server/CGI.py +259 -0
- Pyjo/Server/Daemon.py +1007 -0
- Pyjo/Server/FastCGI.py +517 -0
- Pyjo/Server/Hot/__init__.py +687 -0
- Pyjo/Server/Hot/__main__.py +5 -0
- Pyjo/Server/Lambda.py +641 -0
- Pyjo/Server/Prefork.py +917 -0
- Pyjo/Server/Prethread.py +394 -0
- Pyjo/Server/Reload/Backend/Poll.py +114 -0
- Pyjo/Server/Reload/Backend/__init__.py +126 -0
- Pyjo/Server/Reload/__init__.py +482 -0
- Pyjo/Server/Reload/__main__.py +5 -0
- Pyjo/Server/Stdio.py +228 -0
- Pyjo/Server/WSGI.py +349 -0
- Pyjo/Server/__init__.py +396 -0
- Pyjo/String/Bytes.py +532 -0
- Pyjo/String/HTML.py +210 -0
- Pyjo/String/Mixin.py +333 -0
- Pyjo/String/Template.py +470 -0
- Pyjo/String/Unicode.py +553 -0
- Pyjo/String/__init__.py +0 -0
- Pyjo/TOML.py +232 -0
- Pyjo/Template.py +1290 -0
- Pyjo/Test/Pyjo.py +1454 -0
- Pyjo/Test/__init__.py +1233 -0
- Pyjo/Test/pytest_plugin.py +305 -0
- Pyjo/Transaction/HTTP.py +374 -0
- Pyjo/Transaction/WebSocket.py +709 -0
- Pyjo/Transaction/__init__.py +429 -0
- Pyjo/URL/Template.py +459 -0
- Pyjo/URL/__init__.py +651 -0
- Pyjo/Upload.py +139 -0
- Pyjo/UserAgent/CookieJar.py +391 -0
- Pyjo/UserAgent/Proxy.py +138 -0
- Pyjo/UserAgent/Server.py +239 -0
- Pyjo/UserAgent/Transactor.py +766 -0
- Pyjo/UserAgent/__init__.py +1243 -0
- Pyjo/Util.py +3567 -0
- Pyjo/WebSocket.py +332 -0
- Pyjo/YAML.py +1167 -0
- Pyjo/__init__.py +441 -0
- Pyjo/__main__.py +35 -0
- Pyjo/py.typed +0 -0
- Pyjoyment/Command/Author/__init__.py +20 -0
- Pyjoyment/Command/Author/generate/__init__.py +106 -0
- Pyjoyment/Command/Author/generate/app.py +299 -0
- Pyjoyment/Command/Author/generate/dockerfile.py +281 -0
- Pyjoyment/Command/Author/generate/kubernetes.py +514 -0
- Pyjoyment/Command/Author/generate/lambda_zip.py +372 -0
- Pyjoyment/Command/Author/generate/lite_app.py +113 -0
- Pyjoyment/Command/Author/generate/plugin.py +267 -0
- Pyjoyment/Command/Author/generate/pyproject.py +123 -0
- Pyjoyment/Command/Author/inflate.py +101 -0
- Pyjoyment/Command/__init__.py +332 -0
- Pyjoyment/Command/asgi.py +92 -0
- Pyjoyment/Command/cgi.py +90 -0
- Pyjoyment/Command/daemon.py +160 -0
- Pyjoyment/Command/eval.py +128 -0
- Pyjoyment/Command/fastcgi.py +99 -0
- Pyjoyment/Command/get.py +305 -0
- Pyjoyment/Command/lambda.py +117 -0
- Pyjoyment/Command/prefork.py +181 -0
- Pyjoyment/Command/prethread.py +126 -0
- Pyjoyment/Command/routes.py +133 -0
- Pyjoyment/Command/stdio.py +92 -0
- Pyjoyment/Command/version.py +141 -0
- Pyjoyment/Command/wsgi.py +93 -0
- Pyjoyment/Commands.py +546 -0
- Pyjoyment/Controller.py +1307 -0
- Pyjoyment/HelloWorld.py +56 -0
- Pyjoyment/Lite.py +494 -0
- Pyjoyment/Plugin/AllowedHosts.py +205 -0
- Pyjoyment/Plugin/Config.py +248 -0
- Pyjoyment/Plugin/DefaultHelpers.py +1202 -0
- Pyjoyment/Plugin/EPLRenderer.py +134 -0
- Pyjoyment/Plugin/EPRenderer.py +212 -0
- Pyjoyment/Plugin/HeaderCondition.py +98 -0
- Pyjoyment/Plugin/Health.py +325 -0
- Pyjoyment/Plugin/JSONConfig.py +172 -0
- Pyjoyment/Plugin/Mount.py +223 -0
- Pyjoyment/Plugin/PyRenderer.py +209 -0
- Pyjoyment/Plugin/TOMLConfig.py +141 -0
- Pyjoyment/Plugin/TagHelpers.py +959 -0
- Pyjoyment/Plugin/YAMLConfig.py +178 -0
- Pyjoyment/Plugin/__init__.py +80 -0
- Pyjoyment/Plugins.py +215 -0
- Pyjoyment/Renderer.py +673 -0
- Pyjoyment/Routes/Match.py +251 -0
- Pyjoyment/Routes/Pattern.py +443 -0
- Pyjoyment/Routes/Route.py +827 -0
- Pyjoyment/Routes/__init__.py +419 -0
- Pyjoyment/Sessions.py +255 -0
- Pyjoyment/Static.py +421 -0
- Pyjoyment/Types.py +238 -0
- Pyjoyment/Validator/Validation.py +395 -0
- Pyjoyment/Validator/__init__.py +304 -0
- Pyjoyment/__init__.py +1083 -0
- Pyjoyment/__main__.py +5 -0
- Pyjoyment/py.typed +0 -0
- Pyjoyment/resources/public/favicon.ico +0 -0
- Pyjoyment/resources/public/favicon.svg +345 -0
- Pyjoyment/resources/public/pyjo/bootstrap/LICENSE +21 -0
- Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.bundle.min.js.map +1 -0
- Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.css +6 -0
- Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.js +7 -0
- Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.min.css.map +1 -0
- Pyjoyment/resources/public/pyjo/failunicat.png +0 -0
- Pyjoyment/resources/public/pyjo/failunicat.svg +50 -0
- Pyjoyment/resources/public/pyjo/highlight.js/LICENSE +29 -0
- Pyjoyment/resources/public/pyjo/highlight.js/highlight-mojo-dark.css +56 -0
- Pyjoyment/resources/public/pyjo/highlight.js/highlight.min.js +1262 -0
- Pyjoyment/resources/public/pyjo/highlight.js/pyjoyment.min.js +1 -0
- Pyjoyment/resources/public/pyjo/logo-black-4x.png +0 -0
- Pyjoyment/resources/public/pyjo/logo-black-4x.svg +346 -0
- Pyjoyment/resources/public/pyjo/logo-white-2x.png +0 -0
- Pyjoyment/resources/public/pyjo/logo-white-2x.svg +346 -0
- Pyjoyment/resources/public/pyjo/logo-white-4x.png +0 -0
- Pyjoyment/resources/public/pyjo/logo-white-4x.svg +346 -0
- Pyjoyment/resources/public/pyjo/logo-white.png +0 -0
- Pyjoyment/resources/public/pyjo/logo-white.svg +346 -0
- Pyjoyment/resources/public/pyjo/logo.png +0 -0
- Pyjoyment/resources/public/pyjo/logo.svg +346 -0
- Pyjoyment/resources/public/pyjo/notfound.png +0 -0
- Pyjoyment/resources/public/pyjo/notfound.svg +3 -0
- Pyjoyment/resources/public/pyjo/nounicat.png +0 -0
- Pyjoyment/resources/public/pyjo/nounicat.svg +5 -0
- Pyjoyment/resources/public/pyjo/pinstripe-dark.png +0 -0
- Pyjoyment/resources/public/pyjo/pinstripe-dark.svg +4 -0
- Pyjoyment/resources/public/pyjo/pinstripe-light.png +0 -0
- Pyjoyment/resources/public/pyjo/pinstripe-light.svg +4 -0
- Pyjoyment/resources/public/pyjo/pyjo.css +204 -0
- Pyjoyment/resources/templates/pyjo/debug.html.ep +346 -0
- Pyjoyment/resources/templates/pyjo/exception.html.ep +32 -0
- Pyjoyment/resources/templates/pyjo/not_found.html.ep +45 -0
- pyjoyment-0.1.0.dist-info/METADATA +376 -0
- pyjoyment-0.1.0.dist-info/RECORD +200 -0
- pyjoyment-0.1.0.dist-info/WHEEL +4 -0
- pyjoyment-0.1.0.dist-info/entry_points.txt +8 -0
- pyjoyment-0.1.0.dist-info/licenses/LICENSE +201 -0
Pyjo/Prove.py
ADDED
|
@@ -0,0 +1,1305 @@
|
|
|
1
|
+
r"""
|
|
2
|
+
Pyjo.Prove - Run test scripts
|
|
3
|
+
=============================
|
|
4
|
+
::
|
|
5
|
+
|
|
6
|
+
$ python -m Pyjo.Prove t/Pyjo t/docs
|
|
7
|
+
$ python -m Pyjo.Prove -r -j 4 t
|
|
8
|
+
$ pyjo-prove -v t/Pyjo/URL.py
|
|
9
|
+
|
|
10
|
+
:mod:`Pyjo.Prove` runs test scripts which print the
|
|
11
|
+
`Test Anything Protocol <https://testanything.org/>`_ (TAP), such as the ones
|
|
12
|
+
written with :mod:`Pyjo.Test`, and reports their results. It is inspired by
|
|
13
|
+
Perl's ``prove`` and accepts the options of it that make sense for Python.
|
|
14
|
+
|
|
15
|
+
A directory runs every script with the extension ``.py`` in it, and with
|
|
16
|
+
``-r`` in its subdirectories too. Without arguments it runs ``t``.
|
|
17
|
+
|
|
18
|
+
A test script passes when it exits with status ``0``, declares a plan, runs as
|
|
19
|
+
many tests as planned and none of them fails, except the ``TODO`` tests. A
|
|
20
|
+
plan of ``1..0 # SKIP reason`` skips the whole script. ``Bail out!`` stops the
|
|
21
|
+
whole run. Indented lines belong to subtests and are not parsed: the parent
|
|
22
|
+
test point stands for the whole subtest.
|
|
23
|
+
|
|
24
|
+
A script with the inline metadata of
|
|
25
|
+
`PEP 723 <https://peps.python.org/pep-0723/>`_ is skipped without running when
|
|
26
|
+
the Python that runs it does not satisfy its ``requires-python``, as a script
|
|
27
|
+
with the syntax of a newer Python, which an older one cannot compile, so that
|
|
28
|
+
the script cannot skip itself. ::
|
|
29
|
+
|
|
30
|
+
# /// script
|
|
31
|
+
# requires-python = ">=3.14"
|
|
32
|
+
# ///
|
|
33
|
+
|
|
34
|
+
The summary ends with ``Result: PASS`` when every test script passed,
|
|
35
|
+
``Result: FAIL`` when one did not, and ``Result: NOTESTS`` when they passed
|
|
36
|
+
without running a test, as when every one of them was skipped. The exit status is ``0`` when every test script passed and ``1`` otherwise.
|
|
37
|
+
Ctrl-C (``SIGINT``) stops the run: the test scripts that are running are
|
|
38
|
+
killed, with the processes they started, and the harness dies of the signal,
|
|
39
|
+
or with ``--trap`` prints the summary of the test scripts that have finished.
|
|
40
|
+
|
|
41
|
+
Options
|
|
42
|
+
-------
|
|
43
|
+
|
|
44
|
+
``-r``, ``--recurse``
|
|
45
|
+
Run the test scripts in subdirectories too.
|
|
46
|
+
|
|
47
|
+
``-j N``, ``--jobs N``
|
|
48
|
+
Run up to N test scripts at the same time, or with ``0`` as many as the
|
|
49
|
+
processors that the harness may use. ::
|
|
50
|
+
|
|
51
|
+
$ python -m Pyjo.Prove -j 0 -r t
|
|
52
|
+
|
|
53
|
+
``prove`` has no such value.
|
|
54
|
+
|
|
55
|
+
``-v``, ``--verbose``
|
|
56
|
+
Print the TAP output of each test script, and set the environment
|
|
57
|
+
variable ``PYJO_HARNESS_IS_VERBOSE`` of the test scripts to ``1``, as
|
|
58
|
+
``prove`` sets ``HARNESS_IS_VERBOSE``, which tells :mod:`Pyjo.Test.Pyjo`
|
|
59
|
+
to log everything.
|
|
60
|
+
|
|
61
|
+
``-I DIR``, ``--include DIR``
|
|
62
|
+
Add the directory to ``PYTHONPATH`` of the test scripts. Can be repeated.
|
|
63
|
+
|
|
64
|
+
``-l``, ``--lib``
|
|
65
|
+
Add the current directory to ``PYTHONPATH`` of the test scripts, as
|
|
66
|
+
``-I .`` does, where ``prove`` adds ``lib``.
|
|
67
|
+
|
|
68
|
+
``--ext EXT``
|
|
69
|
+
Extension of the test scripts in a directory, ``.py`` by default.
|
|
70
|
+
|
|
71
|
+
``--exec CMD``
|
|
72
|
+
Command that runs a test script, the current Python interpreter by default.
|
|
73
|
+
A script whose command cannot start fails with the reason.
|
|
74
|
+
|
|
75
|
+
``--timeout SECONDS``
|
|
76
|
+
The most time a test script may run, no limit by default. A test script
|
|
77
|
+
that runs longer is killed, with the processes it started, and fails.
|
|
78
|
+
|
|
79
|
+
``--trap``
|
|
80
|
+
Print the summary of the test scripts that have finished when Ctrl-C
|
|
81
|
+
interrupts the run.
|
|
82
|
+
|
|
83
|
+
``--capture-stderr``
|
|
84
|
+
Keep the standard error of each test script and print it after the line of
|
|
85
|
+
its result, each line after the name of the script, instead of letting it
|
|
86
|
+
through as the script writes it, where the scripts that run at the same
|
|
87
|
+
time with ``-j`` mix it. ::
|
|
88
|
+
|
|
89
|
+
t/Pyjo/URL.py ........ ok
|
|
90
|
+
t/Pyjo/URL.py: ResourceWarning: unclosed <socket.socket ...>
|
|
91
|
+
|
|
92
|
+
``prove`` has no such option: its ``--merge`` puts the standard error into
|
|
93
|
+
the TAP output, which shows it with ``-v`` only.
|
|
94
|
+
|
|
95
|
+
``--stderr-lines N``
|
|
96
|
+
With ``--capture-stderr``, print at most N lines of the standard error of
|
|
97
|
+
a test script, no limit by default: the first half of them and the last
|
|
98
|
+
half, which ends with what the script said last, and between them a line
|
|
99
|
+
that tells how many were left out. ::
|
|
100
|
+
|
|
101
|
+
t/Pyjo/URL.py: [922554 lines left out]
|
|
102
|
+
|
|
103
|
+
``--count``, ``--no-count``, ``--nocount``
|
|
104
|
+
Show the X/Y count of the tests of a test script while it runs or not,
|
|
105
|
+
renewed once a second. By default it is shown when the output goes to a
|
|
106
|
+
terminal, and never with ``-v``. With ``-j``, a line shows the number of
|
|
107
|
+
the tests of all the test scripts, the seconds of the run and the count of
|
|
108
|
+
each test script that runs. ::
|
|
109
|
+
|
|
110
|
+
t/Pyjo/URL.py ........ 12/?
|
|
111
|
+
===( 158;3 12/? 40/40 3/? )======================================
|
|
112
|
+
|
|
113
|
+
``prove`` shows it on a terminal only, even with ``--count``.
|
|
114
|
+
|
|
115
|
+
``-c``, ``--color``, ``--no-color``, ``--nocolor``
|
|
116
|
+
Color the output or not. By default it is colored when it goes to a
|
|
117
|
+
terminal, unless ``NO_COLOR`` is set or ``TERM`` is ``dumb``: the result of
|
|
118
|
+
a test script that passed and the summary of a run that passed are green,
|
|
119
|
+
what failed is red, what was skipped and the ``TODO`` tests are yellow, and
|
|
120
|
+
so is ``NOTESTS``. With ``--capture-stderr``, the standard error of a
|
|
121
|
+
test script is red.
|
|
122
|
+
|
|
123
|
+
``--formatter NAME[=FILE]``
|
|
124
|
+
Write the results as the formatter ``NAME`` to the file, or in the place
|
|
125
|
+
of the usual output without one, which only one formatter can do. Can be
|
|
126
|
+
repeated. The formatters are:
|
|
127
|
+
|
|
128
|
+
``console``
|
|
129
|
+
The usual output, a line for each test script and the summary, which
|
|
130
|
+
goes to the standard output unless another formatter writes there.
|
|
131
|
+
``junit``
|
|
132
|
+
The JUnit XML of the results, which the reporters of CI systems read,
|
|
133
|
+
as :func:`junit` makes it.
|
|
134
|
+
``markdown``
|
|
135
|
+
The summary of the results in Markdown, as the one of a job of GitHub
|
|
136
|
+
Actions, with a table of what failed and the diagnostics of each test
|
|
137
|
+
script that did not pass, as :func:`markdown` makes it.
|
|
138
|
+
|
|
139
|
+
The diagnostics of the failures come from the standard error of the test
|
|
140
|
+
scripts, which ``--capture-stderr`` captures. ::
|
|
141
|
+
|
|
142
|
+
$ python -m Pyjo.Prove --formatter junit -r t > report.xml
|
|
143
|
+
$ python -m Pyjo.Prove --capture-stderr --formatter junit=report.xml \\
|
|
144
|
+
--formatter "markdown=$GITHUB_STEP_SUMMARY" -r t
|
|
145
|
+
|
|
146
|
+
``prove`` takes the class of one formatter, which writes in the place of
|
|
147
|
+
the usual output, as ``TAP::Formatter::JUnit``.
|
|
148
|
+
|
|
149
|
+
Environment
|
|
150
|
+
-----------
|
|
151
|
+
|
|
152
|
+
``PYJO_PROVE_OPTS``
|
|
153
|
+
Options that go before the ones of the command line, which override them,
|
|
154
|
+
split as a shell splits words, as the command of ``--exec`` is, where a
|
|
155
|
+
backslash is kept on Windows, as in its paths. The test scripts do not
|
|
156
|
+
get it, so that a test script that runs :mod:`Pyjo.Prove` itself runs it
|
|
157
|
+
without them. ::
|
|
158
|
+
|
|
159
|
+
$ PYJO_PROVE_OPTS='-l -j 4' python -m Pyjo.Prove -r t
|
|
160
|
+
|
|
161
|
+
``prove`` reads its options from ``.proverc`` instead.
|
|
162
|
+
|
|
163
|
+
``PYJO_HARNESS_ACTIVE``
|
|
164
|
+
Set to ``1`` for the test scripts, as ``prove`` sets ``HARNESS_ACTIVE``,
|
|
165
|
+
which tells :mod:`Pyjoyment.Commands` not to list the commands.
|
|
166
|
+
|
|
167
|
+
Functions
|
|
168
|
+
---------
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
import argparse
|
|
172
|
+
import asyncio
|
|
173
|
+
import collections
|
|
174
|
+
import contextlib
|
|
175
|
+
import operator
|
|
176
|
+
import os
|
|
177
|
+
import re
|
|
178
|
+
import shlex
|
|
179
|
+
import signal
|
|
180
|
+
import sys
|
|
181
|
+
import time
|
|
182
|
+
import tomllib
|
|
183
|
+
|
|
184
|
+
from collections.abc import Callable, Sequence
|
|
185
|
+
from dataclasses import dataclass, field
|
|
186
|
+
from typing import TYPE_CHECKING, TextIO
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if TYPE_CHECKING:
|
|
190
|
+
import Pyjo.DOM
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
__all__ = ['Point', 'Result', 'find_scripts', 'finish', 'junit', 'main', 'markdown', 'parse', 'run', 'run_script']
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
RE_VERSION = re.compile(r'TAP version (\d+)\s*$')
|
|
197
|
+
RE_PLAN = re.compile(r'1\.\.(\d+)\s*(?:#\s*(.*?))?\s*$')
|
|
198
|
+
RE_TEST = re.compile(
|
|
199
|
+
r'(not )?ok\b\s*(\d+)?\s*(?:-\s*)?((?:\\.|[^#\\])*?)\s*(?:#\s*(skip|todo)\b\s*(.*?))?\s*$',
|
|
200
|
+
re.IGNORECASE,
|
|
201
|
+
)
|
|
202
|
+
RE_BAIL_OUT = re.compile(r'Bail out!\s*(.*?)\s*$')
|
|
203
|
+
RE_SKIP = re.compile(r'skip\b\s*(.*)', re.IGNORECASE)
|
|
204
|
+
RE_METADATA = re.compile(r'(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$')
|
|
205
|
+
RE_FAILED = re.compile(r'#\s+Failed (?:\(TODO\) )?test\b')
|
|
206
|
+
RE_AT = re.compile(r'\bat (.+?) line (\d+)\.?$')
|
|
207
|
+
RE_ANSI = re.compile(r'\x1b\[[0-9;?]*[A-Za-z]')
|
|
208
|
+
RE_NOT_XML = re.compile(r'[^\t\n\r\x20-\ud7ff\ue000-\ufffd\U00010000-\U0010ffff]')
|
|
209
|
+
RE_SPECIFIER = re.compile(r'\s*(~=|===|==|!=|<=|>=|<|>)\s*v?(\d+(?:\.\d+)*)(\.\*)?\s*')
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass
|
|
213
|
+
class Point:
|
|
214
|
+
"""
|
|
215
|
+
A test point of the TAP output of a test script, with the lines of its
|
|
216
|
+
subtest and the diagnostics after it when it fails.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
number: int
|
|
220
|
+
name: str = ''
|
|
221
|
+
ok: bool = True
|
|
222
|
+
directive: str | None = None
|
|
223
|
+
reason: str = ''
|
|
224
|
+
time: float = 0.0
|
|
225
|
+
diagnostics: list[str] = field(default_factory=lambda: [])
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def failed(self) -> bool:
|
|
229
|
+
"""::
|
|
230
|
+
|
|
231
|
+
failed = point.failed
|
|
232
|
+
|
|
233
|
+
True if the test point failed, and is not a ``TODO`` test.
|
|
234
|
+
"""
|
|
235
|
+
return not self.ok and self.directive != 'todo'
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def skipped(self) -> bool:
|
|
239
|
+
"""::
|
|
240
|
+
|
|
241
|
+
skipped = point.skipped
|
|
242
|
+
|
|
243
|
+
True if the test point was skipped, or is a ``TODO`` test that failed,
|
|
244
|
+
as expected.
|
|
245
|
+
"""
|
|
246
|
+
return self.directive == 'skip' or (not self.ok and self.directive == 'todo')
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@dataclass
|
|
250
|
+
class Result:
|
|
251
|
+
"""
|
|
252
|
+
Result of a test script: what its TAP output says and how it exited.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
path: str
|
|
256
|
+
planned: int | None = None
|
|
257
|
+
skip_all: str | None = None
|
|
258
|
+
tests: int = 0
|
|
259
|
+
failed: list[int] = field(default_factory=lambda: [])
|
|
260
|
+
todo_passed: list[int] = field(default_factory=lambda: [])
|
|
261
|
+
skipped: int = 0
|
|
262
|
+
bail_out: str | None = None
|
|
263
|
+
exit_status: int = 0
|
|
264
|
+
timed_out: float | None = None
|
|
265
|
+
errors: list[str] = field(default_factory=lambda: [])
|
|
266
|
+
output: list[str] = field(default_factory=lambda: [])
|
|
267
|
+
stderr: list[str] = field(default_factory=lambda: [])
|
|
268
|
+
stderr_left_out: int = 0
|
|
269
|
+
points: list[Point] = field(default_factory=lambda: [])
|
|
270
|
+
time: float = 0.0
|
|
271
|
+
pending: list[str] = field(default_factory=lambda: [])
|
|
272
|
+
mark: float | None = None
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def passed(self) -> bool:
|
|
276
|
+
"""::
|
|
277
|
+
|
|
278
|
+
ok = result.passed
|
|
279
|
+
|
|
280
|
+
True if the test script passed.
|
|
281
|
+
"""
|
|
282
|
+
return not self.failed and not self.errors and self.bail_out is None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def parse(result: Result, line: str, at: float | None = None) -> None:
|
|
286
|
+
"""::
|
|
287
|
+
|
|
288
|
+
result = Result('t/test.py')
|
|
289
|
+
for line in output.splitlines():
|
|
290
|
+
parse(result, line)
|
|
291
|
+
parse(result, line, at=time.monotonic())
|
|
292
|
+
|
|
293
|
+
Parse a line of TAP output into the result, which came at the time ``at``,
|
|
294
|
+
of :func:`time.monotonic`, for the time of its test point.
|
|
295
|
+
"""
|
|
296
|
+
result.output.append(line)
|
|
297
|
+
|
|
298
|
+
# The lines of a subtest, and its name, belong to the test point that follows them, and a comment to the one before
|
|
299
|
+
# it, as the diagnostics of a failure come after it
|
|
300
|
+
if not line or line[0].isspace() or line.startswith('# Subtest'):
|
|
301
|
+
if line:
|
|
302
|
+
result.pending.append(line)
|
|
303
|
+
return
|
|
304
|
+
if line.startswith('#'):
|
|
305
|
+
if result.points and not result.points[-1].ok:
|
|
306
|
+
result.points[-1].diagnostics.append(line)
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
if m := RE_TEST.match(line):
|
|
310
|
+
not_ok, number, name, directive, reason = m.groups()
|
|
311
|
+
result.tests += 1
|
|
312
|
+
point = Point(
|
|
313
|
+
result.tests,
|
|
314
|
+
re.sub(r'\\(.)', r'\1', name or ''),
|
|
315
|
+
not not_ok,
|
|
316
|
+
(directive or '').lower() or None,
|
|
317
|
+
reason or '',
|
|
318
|
+
)
|
|
319
|
+
if at is not None:
|
|
320
|
+
point.time = max(at - (result.mark if result.mark is not None else at), 0.0)
|
|
321
|
+
result.mark = at
|
|
322
|
+
if not_ok:
|
|
323
|
+
point.diagnostics = result.pending
|
|
324
|
+
result.pending = []
|
|
325
|
+
result.points.append(point)
|
|
326
|
+
if number is not None and int(number) != result.tests:
|
|
327
|
+
result.errors.append(f'Tests out of sequence. Found ({number}) but expected ({result.tests})')
|
|
328
|
+
directive = (directive or '').lower()
|
|
329
|
+
if directive == 'skip':
|
|
330
|
+
result.skipped += 1
|
|
331
|
+
if not_ok and directive != 'todo':
|
|
332
|
+
result.failed.append(result.tests)
|
|
333
|
+
if not not_ok and directive == 'todo':
|
|
334
|
+
result.todo_passed.append(result.tests)
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
if m := RE_PLAN.match(line):
|
|
338
|
+
if result.planned is not None:
|
|
339
|
+
result.errors.append('More than one plan found in TAP output')
|
|
340
|
+
return
|
|
341
|
+
result.planned = int(m.group(1))
|
|
342
|
+
if result.planned == 0 and (skip := RE_SKIP.match(m.group(2) or '')):
|
|
343
|
+
result.skip_all = skip.group(1)
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
if m := RE_BAIL_OUT.match(line):
|
|
347
|
+
result.bail_out = m.group(1)
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
if RE_VERSION.match(line) and len(result.output) > 1:
|
|
351
|
+
result.errors.append('TAP version must be the first line of the output')
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def finish(result: Result) -> None:
|
|
355
|
+
"""::
|
|
356
|
+
|
|
357
|
+
finish(result)
|
|
358
|
+
|
|
359
|
+
Check the result once the test script has exited: the plan, the number of
|
|
360
|
+
tests and the exit status. The diagnostics of the failures in the standard
|
|
361
|
+
error that was captured, as :mod:`Pyjo.Test` and ``Test::More`` write them,
|
|
362
|
+
go to the test points that failed, in their order, with the diagnostics of
|
|
363
|
+
the subtest before each of them.
|
|
364
|
+
"""
|
|
365
|
+
_diagnostics(result)
|
|
366
|
+
if result.bail_out is not None:
|
|
367
|
+
return
|
|
368
|
+
if result.timed_out is not None:
|
|
369
|
+
result.errors.append(f'Timed out after {result.timed_out:g} seconds')
|
|
370
|
+
return
|
|
371
|
+
if result.planned is None:
|
|
372
|
+
result.errors.append('No plan found in TAP output')
|
|
373
|
+
elif result.planned != result.tests:
|
|
374
|
+
result.errors.append(f'Bad plan. You planned {result.planned} tests but ran {result.tests}.')
|
|
375
|
+
if result.exit_status:
|
|
376
|
+
result.errors.append(f'Non-zero exit status: {result.exit_status}')
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
async def run_script(
|
|
380
|
+
path: str,
|
|
381
|
+
command: Sequence[str],
|
|
382
|
+
env: dict[str, str],
|
|
383
|
+
*,
|
|
384
|
+
verbose: bool = False,
|
|
385
|
+
stream: bool = False,
|
|
386
|
+
timeout: float | None = None,
|
|
387
|
+
capture_stderr: bool = False,
|
|
388
|
+
stderr_lines: int | None = None,
|
|
389
|
+
color: bool = False,
|
|
390
|
+
progress: Callable[[Result], object] | None = None,
|
|
391
|
+
cwd: str | None = None,
|
|
392
|
+
) -> Result:
|
|
393
|
+
"""::
|
|
394
|
+
|
|
395
|
+
result = await run_script('t/test.py', [sys.executable], dict(os.environ))
|
|
396
|
+
result = await run_script('t/test.py', [sys.executable], dict(os.environ), timeout=60)
|
|
397
|
+
result = await run_script('t/test.py', [sys.executable], dict(os.environ), capture_stderr=True)
|
|
398
|
+
result = await run_script('t/test.py', [sys.executable], dict(os.environ), capture_stderr=True, stderr_lines=100)
|
|
399
|
+
result = await run_script('t/test.py', [sys.executable], dict(os.environ), progress=print)
|
|
400
|
+
result = await run_script('t/test.py', [sys.executable], dict(os.environ), cwd='project')
|
|
401
|
+
|
|
402
|
+
Run a test script and parse its TAP output. The standard error of the
|
|
403
|
+
script goes to the standard error of this process, or with
|
|
404
|
+
``capture_stderr`` to the lines of :attr:`Result.stderr`, at most
|
|
405
|
+
``stderr_lines`` of them, the first half and the last half, with the number
|
|
406
|
+
of the lines between them in :attr:`Result.stderr_left_out`. With ``verbose``
|
|
407
|
+
and ``stream``, the TAP output is printed as it comes, colored with
|
|
408
|
+
``color``. ``progress`` is called with the result when the script starts
|
|
409
|
+
and after each of its tests. A script that runs
|
|
410
|
+
longer than ``timeout`` seconds is killed, with the processes it started.
|
|
411
|
+
A script whose ``requires-python`` the Python of the command does not
|
|
412
|
+
satisfy is skipped without running.
|
|
413
|
+
The script runs in the directory ``cwd``, the current one by default, which
|
|
414
|
+
its path is relative to.
|
|
415
|
+
"""
|
|
416
|
+
result = Result(path)
|
|
417
|
+
try:
|
|
418
|
+
requires = _requires_python(os.path.join(cwd, path) if cwd is not None else path)
|
|
419
|
+
if requires is not None and (version := await _python(command)) is not None:
|
|
420
|
+
if not _satisfies(version, requires):
|
|
421
|
+
result.planned = 0
|
|
422
|
+
result.skip_all = f'requires Python {requires}'
|
|
423
|
+
return result
|
|
424
|
+
except ValueError as e:
|
|
425
|
+
result.errors.append(f'Invalid script metadata: {e}')
|
|
426
|
+
return result
|
|
427
|
+
|
|
428
|
+
# The script leads a process group of its own, so that the processes it starts are killed with it; a command that
|
|
429
|
+
# cannot start fails the script with the reason, and the run goes on
|
|
430
|
+
try:
|
|
431
|
+
process = await asyncio.create_subprocess_exec(
|
|
432
|
+
*command,
|
|
433
|
+
path,
|
|
434
|
+
stdout=asyncio.subprocess.PIPE,
|
|
435
|
+
stderr=asyncio.subprocess.PIPE if capture_stderr else None,
|
|
436
|
+
env=env,
|
|
437
|
+
cwd=cwd,
|
|
438
|
+
process_group=0 if hasattr(os, 'killpg') else None,
|
|
439
|
+
)
|
|
440
|
+
except OSError as e:
|
|
441
|
+
result.errors.append(f"Can't run {command[0]}: {e.strerror or e}")
|
|
442
|
+
return result
|
|
443
|
+
job = _job(process.pid)
|
|
444
|
+
assert process.stdout is not None
|
|
445
|
+
started = result.mark = time.monotonic()
|
|
446
|
+
if progress is not None:
|
|
447
|
+
progress(result)
|
|
448
|
+
stderr = (
|
|
449
|
+
asyncio.create_task(_read_lines(process.stderr, result, stderr_lines)) if process.stderr is not None else None
|
|
450
|
+
)
|
|
451
|
+
try:
|
|
452
|
+
async with asyncio.timeout(timeout):
|
|
453
|
+
while raw := await process.stdout.readline():
|
|
454
|
+
line = raw.decode('utf-8', 'replace').rstrip('\r\n')
|
|
455
|
+
tests = result.tests
|
|
456
|
+
parse(result, line, at=time.monotonic())
|
|
457
|
+
if progress is not None and result.tests != tests:
|
|
458
|
+
progress(result)
|
|
459
|
+
if verbose and stream:
|
|
460
|
+
print(_tap_line(line, color), flush=True)
|
|
461
|
+
result.exit_status = await process.wait()
|
|
462
|
+
except TimeoutError:
|
|
463
|
+
result.timed_out = timeout
|
|
464
|
+
_kill(process, job)
|
|
465
|
+
result.exit_status = await process.wait()
|
|
466
|
+
finally:
|
|
467
|
+
# Ctrl-C reaches the foreground process group only, not the one of the script, which the harness waits for,
|
|
468
|
+
# so that it does not outlive the harness
|
|
469
|
+
if process.returncode is None:
|
|
470
|
+
_kill(process, job)
|
|
471
|
+
await process.wait()
|
|
472
|
+
_close_job(job)
|
|
473
|
+
# A process that the script started may keep the standard error open after the script exits
|
|
474
|
+
if stderr is not None:
|
|
475
|
+
try:
|
|
476
|
+
await asyncio.wait_for(stderr, 5)
|
|
477
|
+
except TimeoutError:
|
|
478
|
+
pass
|
|
479
|
+
result.time = time.monotonic() - started
|
|
480
|
+
finish(result)
|
|
481
|
+
return result
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def find_scripts(paths: Sequence[str], ext: str, recurse: bool) -> list[str]:
|
|
485
|
+
"""::
|
|
486
|
+
|
|
487
|
+
scripts = find_scripts(['t'], '.py', recurse=True)
|
|
488
|
+
|
|
489
|
+
Expand directories into the test scripts in them, sorted by name. The
|
|
490
|
+
names of the scripts found in a directory have ``/`` between their
|
|
491
|
+
levels on every system, as ``prove`` gives them.
|
|
492
|
+
"""
|
|
493
|
+
scripts: list[str] = []
|
|
494
|
+
for path in paths:
|
|
495
|
+
if not os.path.isdir(path):
|
|
496
|
+
scripts.append(path)
|
|
497
|
+
continue
|
|
498
|
+
found: list[str] = []
|
|
499
|
+
for root, dirs, files in os.walk(path):
|
|
500
|
+
dirs.sort()
|
|
501
|
+
# The names with /, as prove gives them on every system
|
|
502
|
+
found.extend(os.path.join(root, f).replace(os.sep, '/') for f in files if f.endswith(ext))
|
|
503
|
+
if not recurse:
|
|
504
|
+
break
|
|
505
|
+
scripts.extend(sorted(found))
|
|
506
|
+
return scripts
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
async def run(
|
|
510
|
+
scripts: Sequence[str],
|
|
511
|
+
command: Sequence[str] | None = None,
|
|
512
|
+
env: dict[str, str] | None = None,
|
|
513
|
+
*,
|
|
514
|
+
jobs: int = 1,
|
|
515
|
+
verbose: bool = False,
|
|
516
|
+
timeout: float | None = None,
|
|
517
|
+
trap: bool = False,
|
|
518
|
+
capture_stderr: bool = False,
|
|
519
|
+
stderr_lines: int | None = None,
|
|
520
|
+
color: bool | None = None,
|
|
521
|
+
count: bool | None = None,
|
|
522
|
+
console: bool = True,
|
|
523
|
+
) -> list[Result]:
|
|
524
|
+
"""::
|
|
525
|
+
|
|
526
|
+
results = asyncio.run(run(['t/a.py', 't/b.py'], jobs=2))
|
|
527
|
+
results = asyncio.run(run(['t/a.py', 't/b.py'], jobs=0))
|
|
528
|
+
results = asyncio.run(run(['t/a.py', 't/b.py'], timeout=60, trap=True))
|
|
529
|
+
results = asyncio.run(run(['t/a.py', 't/b.py'], jobs=2, capture_stderr=True))
|
|
530
|
+
results = asyncio.run(run(['t/a.py', 't/b.py'], jobs=2, capture_stderr=True, stderr_lines=1000))
|
|
531
|
+
results = asyncio.run(run(['t/a.py', 't/b.py'], color=False))
|
|
532
|
+
results = asyncio.run(run(['t/a.py', 't/b.py'], count=False))
|
|
533
|
+
results = asyncio.run(run(['t/a.py', 't/b.py'], console=False))
|
|
534
|
+
|
|
535
|
+
Run the test scripts, up to ``jobs`` of them at the same time, or with
|
|
536
|
+
``0`` as many as the processors that the process may use, print a line
|
|
537
|
+
for each of them and a summary, and return the results in the order of
|
|
538
|
+
the scripts. With ``trap``, a run that
|
|
539
|
+
Ctrl-C interrupts prints the summary and returns the results of the test
|
|
540
|
+
scripts that have finished. The output is colored with ``color``, and the
|
|
541
|
+
tests of the test scripts are counted while they run with ``count``, both
|
|
542
|
+
by default when the output goes to a terminal, the count without
|
|
543
|
+
``verbose`` only. Without ``console``, nothing goes to the standard output,
|
|
544
|
+
as when another formatter writes there, and the standard error that was
|
|
545
|
+
captured still goes to the standard error.
|
|
546
|
+
"""
|
|
547
|
+
command = list(command or [sys.executable])
|
|
548
|
+
env = dict(os.environ if env is None else env)
|
|
549
|
+
# Not in prove: no number of jobs, which is as many as the processors that the process may use
|
|
550
|
+
if jobs == 0:
|
|
551
|
+
jobs = _cpu_count()
|
|
552
|
+
# Not in prove: PYJO_HARNESS_ACTIVE and PYJO_HARNESS_IS_VERBOSE, where prove sets HARNESS_ACTIVE and
|
|
553
|
+
# HARNESS_IS_VERBOSE
|
|
554
|
+
env['PYJO_HARNESS_ACTIVE'] = '1'
|
|
555
|
+
if verbose:
|
|
556
|
+
env['PYJO_HARNESS_IS_VERBOSE'] = '1'
|
|
557
|
+
width = max((len(s) for s in scripts), default=0) + 3
|
|
558
|
+
semaphore = asyncio.Semaphore(max(jobs, 1))
|
|
559
|
+
bailed_out = asyncio.Event()
|
|
560
|
+
started = time.monotonic()
|
|
561
|
+
out_color = _color_default(sys.stdout) if color is None else color
|
|
562
|
+
err_color = _color_default(sys.stderr) if color is None else color
|
|
563
|
+
counter = (
|
|
564
|
+
_Count(width, jobs > 1, out_color)
|
|
565
|
+
if console and not verbose and (_tty(sys.stdout) if count is None else count)
|
|
566
|
+
else None
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
finished: dict[int, Result] = {}
|
|
570
|
+
interrupted = False
|
|
571
|
+
|
|
572
|
+
async def one(i: int, path: str) -> None:
|
|
573
|
+
async with semaphore:
|
|
574
|
+
if bailed_out.is_set():
|
|
575
|
+
return
|
|
576
|
+
stream = jobs <= 1
|
|
577
|
+
if stream and verbose and console:
|
|
578
|
+
print(f'{path} ..', flush=True)
|
|
579
|
+
result = await run_script(
|
|
580
|
+
path,
|
|
581
|
+
command,
|
|
582
|
+
env,
|
|
583
|
+
verbose=verbose and console,
|
|
584
|
+
stream=stream,
|
|
585
|
+
timeout=timeout,
|
|
586
|
+
capture_stderr=capture_stderr,
|
|
587
|
+
stderr_lines=stderr_lines,
|
|
588
|
+
color=out_color,
|
|
589
|
+
progress=counter.update if counter is not None else None,
|
|
590
|
+
)
|
|
591
|
+
if counter is not None:
|
|
592
|
+
counter.finish(result)
|
|
593
|
+
if verbose and not stream and console:
|
|
594
|
+
print(f'{path} ..', *(_tap_line(line, out_color) for line in result.output), sep='\n', flush=True)
|
|
595
|
+
if console:
|
|
596
|
+
print(_status_line(result, width, out_color), flush=True)
|
|
597
|
+
lines = [f'{path}: {line}' for line in result.stderr]
|
|
598
|
+
if result.stderr_left_out:
|
|
599
|
+
lines.insert(_head(stderr_lines or 0), f'{path}: [{result.stderr_left_out} lines left out]')
|
|
600
|
+
for line in lines:
|
|
601
|
+
print(_paint(line, 'red', err_color), file=sys.stderr, flush=True)
|
|
602
|
+
if counter is not None:
|
|
603
|
+
counter.show(refresh=True)
|
|
604
|
+
if result.bail_out is not None:
|
|
605
|
+
bailed_out.set()
|
|
606
|
+
finished[i] = result
|
|
607
|
+
|
|
608
|
+
try:
|
|
609
|
+
if jobs <= 1:
|
|
610
|
+
for i, path in enumerate(scripts):
|
|
611
|
+
await one(i, path)
|
|
612
|
+
else:
|
|
613
|
+
await asyncio.gather(*(one(i, path) for i, path in enumerate(scripts)))
|
|
614
|
+
except asyncio.CancelledError:
|
|
615
|
+
# Ctrl-C, which asyncio.run() turns into the cancellation of the run
|
|
616
|
+
if not trap:
|
|
617
|
+
raise
|
|
618
|
+
interrupted = True
|
|
619
|
+
if counter is not None:
|
|
620
|
+
counter.clear()
|
|
621
|
+
if console:
|
|
622
|
+
print(flush=True)
|
|
623
|
+
|
|
624
|
+
done = [finished[i] for i in sorted(finished)]
|
|
625
|
+
if console:
|
|
626
|
+
_summary(done, time.monotonic() - started, interrupted, color=out_color, err_color=err_color)
|
|
627
|
+
return done
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def junit(results: Sequence[Result]) -> 'Pyjo.DOM.object':
|
|
631
|
+
"""::
|
|
632
|
+
|
|
633
|
+
report = junit(results)
|
|
634
|
+
Path('report.xml').write_text(str(report))
|
|
635
|
+
|
|
636
|
+
The JUnit XML of the results, as the reporters of CI systems read it, as a
|
|
637
|
+
:mod:`Pyjo.DOM` object: a ``testsuite`` for each test script, with a
|
|
638
|
+
``testcase`` for each of its test points, whose ``failure`` has the
|
|
639
|
+
diagnostics of a test point that failed, the lines of its subtest and the
|
|
640
|
+
comments after it, and whose ``skipped`` has the reason of a test point
|
|
641
|
+
that was skipped, or is a ``TODO`` test that failed. A ``testcase`` named
|
|
642
|
+
after the script stands for what the script did wrong as a whole, as an
|
|
643
|
+
``error``, as dying, a bad plan or a timeout, or for all its tests that it
|
|
644
|
+
skipped. A test case has the ``line`` of the first ``at FILE line N`` of
|
|
645
|
+
its diagnostics, which :mod:`Pyjo.Test` writes, where the reporters
|
|
646
|
+
annotate the failure. The TAP output of a script that did not pass, and
|
|
647
|
+
the standard error that was captured, are its ``system-out`` and
|
|
648
|
+
``system-err``.
|
|
649
|
+
"""
|
|
650
|
+
import Pyjo.DOM
|
|
651
|
+
|
|
652
|
+
report = Pyjo.DOM.new('<?xml version="1.0" encoding="UTF-8"?>\n<testsuites/>')
|
|
653
|
+
root = report.at('testsuites')
|
|
654
|
+
assert root is not None
|
|
655
|
+
totals: collections.Counter[str] = collections.Counter()
|
|
656
|
+
for result in results:
|
|
657
|
+
counts: collections.Counter[str] = collections.Counter()
|
|
658
|
+
cases: list[Pyjo.DOM.object] = []
|
|
659
|
+
for point in result.points:
|
|
660
|
+
name = _point_name(point)
|
|
661
|
+
case = report.new_tag(
|
|
662
|
+
'testcase', classname=result.path, name=_xml(name), file=result.path, time=f'{point.time:.3f}'
|
|
663
|
+
)
|
|
664
|
+
if point.failed:
|
|
665
|
+
counts['failures'] += 1
|
|
666
|
+
_junit_line(case, point.diagnostics)
|
|
667
|
+
_junit_child(case, 'failure', '\n'.join(point.diagnostics), message=f'not ok {name}', type='TAP')
|
|
668
|
+
elif point.skipped:
|
|
669
|
+
counts['skipped'] += 1
|
|
670
|
+
reason = point.reason or ('TODO' if point.directive == 'todo' else 'skipped')
|
|
671
|
+
_junit_child(case, 'skipped', None, message=reason)
|
|
672
|
+
cases.append(case)
|
|
673
|
+
problems = _problems(result)
|
|
674
|
+
if problems or result.skip_all is not None:
|
|
675
|
+
case = report.new_tag('testcase', classname=result.path, name=result.path, file=result.path, time='0.000')
|
|
676
|
+
if problems:
|
|
677
|
+
counts['errors'] += 1
|
|
678
|
+
_junit_child(case, 'error', '\n'.join([*problems, *result.pending]), message=problems[0], type='TAP')
|
|
679
|
+
else:
|
|
680
|
+
counts['skipped'] += 1
|
|
681
|
+
_junit_child(case, 'skipped', None, message=result.skip_all or 'skipped')
|
|
682
|
+
cases.append(case)
|
|
683
|
+
suite = report.new_tag(
|
|
684
|
+
'testsuite',
|
|
685
|
+
name=result.path,
|
|
686
|
+
tests=str(len(cases)),
|
|
687
|
+
failures=str(counts['failures']),
|
|
688
|
+
errors=str(counts['errors']),
|
|
689
|
+
skipped=str(counts['skipped']),
|
|
690
|
+
time=f'{result.time:.3f}',
|
|
691
|
+
file=result.path,
|
|
692
|
+
)
|
|
693
|
+
tag = suite.at('testsuite')
|
|
694
|
+
assert tag is not None
|
|
695
|
+
for case in cases:
|
|
696
|
+
tag.append_content(case)
|
|
697
|
+
if not result.passed:
|
|
698
|
+
_junit_child(suite, 'system-out', '\n'.join(result.output))
|
|
699
|
+
if result.stderr:
|
|
700
|
+
stderr = list(result.stderr)
|
|
701
|
+
if result.stderr_left_out:
|
|
702
|
+
stderr.insert(_head(len(stderr)), f'[{result.stderr_left_out} lines left out]')
|
|
703
|
+
_junit_child(suite, 'system-err', '\n'.join(stderr))
|
|
704
|
+
root.append_content(suite).append_content('\n')
|
|
705
|
+
totals.update(counts, tests=len(cases))
|
|
706
|
+
root.attr(
|
|
707
|
+
name='Pyjo.Prove',
|
|
708
|
+
tests=str(totals['tests']),
|
|
709
|
+
failures=str(totals['failures']),
|
|
710
|
+
errors=str(totals['errors']),
|
|
711
|
+
skipped=str(totals['skipped']),
|
|
712
|
+
time=f'{sum(result.time for result in results):.3f}',
|
|
713
|
+
)
|
|
714
|
+
return report
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def markdown(results: Sequence[Result], elapsed: float, title: str = 'Pyjo.Prove') -> str:
|
|
718
|
+
"""::
|
|
719
|
+
|
|
720
|
+
summary = markdown(results, 37.2)
|
|
721
|
+
summary = markdown(results, 37.2, title='Tests of Pyjo')
|
|
722
|
+
|
|
723
|
+
The summary of the results in Markdown, as GitHub Actions shows it as the
|
|
724
|
+
summary of a job: the numbers of the files and of the tests that passed,
|
|
725
|
+
were skipped and failed, as the test cases of :func:`junit` count them,
|
|
726
|
+
with a test script that skipped all its tests or failed as a whole as one
|
|
727
|
+
of them, the time, and the result, ``PASS``,
|
|
728
|
+
``FAIL`` or ``NOTESTS``, with a table of what failed, and the diagnostics
|
|
729
|
+
and the standard error of each test script that did not pass, folded.
|
|
730
|
+
"""
|
|
731
|
+
points = [point for result in results for point in result.points]
|
|
732
|
+
failed = [result for result in results if not result.passed]
|
|
733
|
+
failures = sum(point.failed for point in points) + sum(bool(_problems(result)) for result in results)
|
|
734
|
+
skipped = sum(point.skipped for point in points) + sum(r.skip_all is not None for r in results)
|
|
735
|
+
passed = sum(not point.failed and not point.skipped for point in points)
|
|
736
|
+
status = 'FAIL' if failed else 'PASS' if points else 'NOTESTS'
|
|
737
|
+
lines = [
|
|
738
|
+
f'### {title}',
|
|
739
|
+
'',
|
|
740
|
+
'| Files | Tests | Passed ✅ | Skipped ⚠️ | Failed ❌ | Time ⏱️ |',
|
|
741
|
+
'| ---: | ---: | ---: | ---: | ---: | ---: |',
|
|
742
|
+
f'| {len(results)} | {passed + skipped + failures} | {passed} | {skipped} | {failures} | {elapsed:.0f}s |',
|
|
743
|
+
'',
|
|
744
|
+
f'Result: **{status}**',
|
|
745
|
+
]
|
|
746
|
+
if failed:
|
|
747
|
+
lines += ['', '| Test | Result |', '| :--- | :--- |']
|
|
748
|
+
for result in failed:
|
|
749
|
+
path = _cell(result.path)
|
|
750
|
+
lines += [f'| {path} {_cell(_point_name(point))} | ❌ failed |' for point in result.points if point.failed]
|
|
751
|
+
lines += [f'| {path} | ❌ {_cell(problem)} |' for problem in _problems(result)]
|
|
752
|
+
for result in failed:
|
|
753
|
+
# The diagnostics of the failures, and the standard error but them, as warnings and tracebacks
|
|
754
|
+
details = [line for point in result.points if point.failed for line in point.diagnostics]
|
|
755
|
+
details += [*result.pending, *(line for line in result.stderr if not line.lstrip().startswith('#'))]
|
|
756
|
+
details = [RE_ANSI.sub('', line) for line in details]
|
|
757
|
+
if len(details) > _DETAILS:
|
|
758
|
+
details[_DETAILS // 2 : -_DETAILS // 2] = [f'[{len(details) - _DETAILS} lines left out]']
|
|
759
|
+
if details:
|
|
760
|
+
fence = '`' * max([4, *(len(m) + 1 for m in re.findall(r'`{3,}', '\n'.join(details)))])
|
|
761
|
+
lines += ['', f'<details><summary>{_html(result.path)}</summary>', '', f'{fence}text', *details]
|
|
762
|
+
lines += [fence, '', '</details>']
|
|
763
|
+
return '\n'.join(lines) + '\n'
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
767
|
+
"""::
|
|
768
|
+
|
|
769
|
+
sys.exit(main(['-r', 't']))
|
|
770
|
+
|
|
771
|
+
Command line interface: parse the options, after the ones of
|
|
772
|
+
``PYJO_PROVE_OPTS``, run the test scripts and return the exit status.
|
|
773
|
+
"""
|
|
774
|
+
parser = argparse.ArgumentParser(prog='pyjo-prove', description='Run test scripts which print TAP.')
|
|
775
|
+
parser.add_argument('-r', '--recurse', action='store_true', help='run the test scripts in subdirectories too')
|
|
776
|
+
parser.add_argument('-j', '--jobs', type=int, default=1, metavar='N', help='run up to N test scripts at once')
|
|
777
|
+
parser.add_argument('-v', '--verbose', action='store_true', help='print the TAP output of each test script')
|
|
778
|
+
parser.add_argument(
|
|
779
|
+
'-I', '--include', action='append', default=[], metavar='DIR', help='add the directory to PYTHONPATH'
|
|
780
|
+
)
|
|
781
|
+
# Unlike prove: the current directory, where the modules of a project of Python are, as prove adds lib
|
|
782
|
+
parser.add_argument(
|
|
783
|
+
'-l', '--lib', dest='include', action='append_const', const='.', help='add the current directory to PYTHONPATH'
|
|
784
|
+
)
|
|
785
|
+
parser.add_argument('--ext', default='.py', help='extension of the test scripts, .py by default')
|
|
786
|
+
parser.add_argument('--exec', dest='command', metavar='CMD', help='command that runs a test script')
|
|
787
|
+
parser.add_argument(
|
|
788
|
+
'--timeout', type=float, metavar='SECONDS', help='the most time a test script may run, no limit by default'
|
|
789
|
+
)
|
|
790
|
+
parser.add_argument('--trap', action='store_true', help='print the summary when Ctrl-C interrupts the run')
|
|
791
|
+
# Not in prove: --capture-stderr, as --merge of prove shows the standard error with -v only, among the TAP output
|
|
792
|
+
parser.add_argument(
|
|
793
|
+
'--capture-stderr', action='store_true', help='print the standard error of a test script after its result'
|
|
794
|
+
)
|
|
795
|
+
# Not in prove: --stderr-lines, for --capture-stderr
|
|
796
|
+
parser.add_argument(
|
|
797
|
+
'--stderr-lines',
|
|
798
|
+
type=_lines,
|
|
799
|
+
metavar='N',
|
|
800
|
+
help='print at most N lines of the standard error of a test script with --capture-stderr',
|
|
801
|
+
)
|
|
802
|
+
parser.add_argument(
|
|
803
|
+
'--count', action='store_const', const=True, help='show the X/Y count of the tests, by default on a terminal'
|
|
804
|
+
)
|
|
805
|
+
parser.add_argument(
|
|
806
|
+
'--no-count', '--nocount', dest='count', action='store_const', const=False, help='do not show the count'
|
|
807
|
+
)
|
|
808
|
+
parser.add_argument(
|
|
809
|
+
'-c', '--color', action='store_const', const=True, help='color the output, by default on a terminal'
|
|
810
|
+
)
|
|
811
|
+
# Both forms, which Getopt::Long of prove accepts for its --nocolor
|
|
812
|
+
parser.add_argument('--no-color', '--nocolor', dest='color', action='store_const', const=False, help='do not color')
|
|
813
|
+
# Unlike prove: a formatter writes to a file too, so that there are several, as the one of --formatter of prove,
|
|
814
|
+
# which is a class, writes in the place of the output
|
|
815
|
+
parser.add_argument(
|
|
816
|
+
'--formatter',
|
|
817
|
+
action='append',
|
|
818
|
+
default=[],
|
|
819
|
+
metavar='NAME[=FILE]',
|
|
820
|
+
help='write the results as console, junit or markdown, to FILE or in the place of the output',
|
|
821
|
+
)
|
|
822
|
+
parser.add_argument('paths', nargs='*', default=['t'], help='test scripts and directories, t by default')
|
|
823
|
+
# Not in prove: PYJO_PROVE_OPTS, where prove reads .proverc
|
|
824
|
+
try:
|
|
825
|
+
opts = _split(os.environ.get('PYJO_PROVE_OPTS', ''))
|
|
826
|
+
except ValueError as e:
|
|
827
|
+
parser.error(f'PYJO_PROVE_OPTS: {e}')
|
|
828
|
+
args = parser.parse_args([*opts, *(sys.argv[1:] if argv is None else argv)])
|
|
829
|
+
formatters: list[tuple[str, str | None]] = []
|
|
830
|
+
for value in args.formatter:
|
|
831
|
+
name, _, file = value.partition('=')
|
|
832
|
+
if name not in _FORMATTERS:
|
|
833
|
+
parser.error(f'--formatter: unknown formatter {name!r}, one of {", ".join(_FORMATTERS)}')
|
|
834
|
+
formatters.append((name, file or None))
|
|
835
|
+
names, files = [name for name, _ in formatters], [file for _, file in formatters]
|
|
836
|
+
if files.count(None) > 1:
|
|
837
|
+
parser.error('--formatter: one formatter writes in the place of the output, not more')
|
|
838
|
+
# The console in the place of the output, unless another formatter writes there
|
|
839
|
+
if None not in files and 'console' not in names:
|
|
840
|
+
formatters.insert(0, ('console', None))
|
|
841
|
+
|
|
842
|
+
ext = args.ext if args.ext.startswith('.') else f'.{args.ext}'
|
|
843
|
+
scripts = find_scripts(args.paths, ext, args.recurse)
|
|
844
|
+
if not scripts:
|
|
845
|
+
print('No tests found.', file=sys.stderr)
|
|
846
|
+
return 1
|
|
847
|
+
|
|
848
|
+
# A test script that runs the harness does not get the options of this one, as prove leaves out HARNESS_OPTIONS
|
|
849
|
+
env = {name: value for name, value in os.environ.items() if name != 'PYJO_PROVE_OPTS'}
|
|
850
|
+
if args.include:
|
|
851
|
+
env['PYTHONPATH'] = os.pathsep.join([*args.include, *filter(None, [env.get('PYTHONPATH')])])
|
|
852
|
+
|
|
853
|
+
command = _split(args.command) if args.command else [sys.executable]
|
|
854
|
+
console = [file for name, file in formatters if name == 'console']
|
|
855
|
+
started = time.monotonic()
|
|
856
|
+
with contextlib.ExitStack() as stack:
|
|
857
|
+
if console and console[0] is not None:
|
|
858
|
+
stack.enter_context(contextlib.redirect_stdout(stack.enter_context(_open(console[0]))))
|
|
859
|
+
try:
|
|
860
|
+
results = asyncio.run(
|
|
861
|
+
run(
|
|
862
|
+
scripts,
|
|
863
|
+
command,
|
|
864
|
+
env,
|
|
865
|
+
jobs=args.jobs,
|
|
866
|
+
verbose=args.verbose,
|
|
867
|
+
timeout=args.timeout,
|
|
868
|
+
trap=args.trap,
|
|
869
|
+
capture_stderr=args.capture_stderr,
|
|
870
|
+
stderr_lines=args.stderr_lines,
|
|
871
|
+
color=args.color,
|
|
872
|
+
count=args.count,
|
|
873
|
+
console=bool(console),
|
|
874
|
+
)
|
|
875
|
+
)
|
|
876
|
+
except KeyboardInterrupt:
|
|
877
|
+
# Die of the signal, as a process that does not handle it does, without a traceback
|
|
878
|
+
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
|
879
|
+
os.kill(os.getpid(), signal.SIGINT)
|
|
880
|
+
raise
|
|
881
|
+
elapsed = time.monotonic() - started
|
|
882
|
+
for name, file in formatters:
|
|
883
|
+
if name == 'console':
|
|
884
|
+
continue
|
|
885
|
+
report = f'{junit(results)}\n' if name == 'junit' else markdown(results, elapsed)
|
|
886
|
+
if file is None:
|
|
887
|
+
sys.stdout.write(report)
|
|
888
|
+
sys.stdout.flush()
|
|
889
|
+
else:
|
|
890
|
+
with _open(file) as output:
|
|
891
|
+
output.write(report)
|
|
892
|
+
return 0 if len(results) == len(scripts) and all(r.passed for r in results) else 1
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
# The formatters of --formatter, the console, which writes the usual output, first
|
|
896
|
+
_FORMATTERS = ('console', 'junit', 'markdown')
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
# The file of a formatter, whose lines end with LF on every system, where the text mode of Windows writes CRLF
|
|
900
|
+
def _open(path: str) -> TextIO:
|
|
901
|
+
return open(path, 'w', encoding='utf-8', newline='\n')
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
# The version of the Python that a command runs, asked once for a command other than this Python
|
|
905
|
+
_PYTHONS: dict[tuple[str, ...], tuple[int, ...] | None] = {}
|
|
906
|
+
|
|
907
|
+
_OPERATORS: dict[str, Callable[[tuple[int, ...], tuple[int, ...]], bool]] = {
|
|
908
|
+
'===': operator.eq,
|
|
909
|
+
'==': operator.eq,
|
|
910
|
+
'!=': operator.ne,
|
|
911
|
+
'<=': operator.le,
|
|
912
|
+
'>=': operator.ge,
|
|
913
|
+
'<': operator.lt,
|
|
914
|
+
'>': operator.gt,
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
async def _python(command: Sequence[str]) -> tuple[int, ...] | None:
|
|
919
|
+
key = tuple(command)
|
|
920
|
+
if key not in _PYTHONS:
|
|
921
|
+
if list(command) == [sys.executable]:
|
|
922
|
+
_PYTHONS[key] = tuple(sys.version_info[:3])
|
|
923
|
+
else:
|
|
924
|
+
_PYTHONS[key] = None
|
|
925
|
+
try:
|
|
926
|
+
process = await asyncio.create_subprocess_exec(
|
|
927
|
+
*command,
|
|
928
|
+
'-c',
|
|
929
|
+
'import sys; print(*sys.version_info[:3])',
|
|
930
|
+
stdout=asyncio.subprocess.PIPE,
|
|
931
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
932
|
+
)
|
|
933
|
+
out, _ = await process.communicate()
|
|
934
|
+
if process.returncode == 0 and len(version := tuple(int(n) for n in out.split())) == 3:
|
|
935
|
+
_PYTHONS[key] = version
|
|
936
|
+
except (OSError, ValueError):
|
|
937
|
+
pass
|
|
938
|
+
return _PYTHONS[key]
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _cpu_count() -> int:
|
|
942
|
+
# The processors that the process may use, as its affinity limits them, which Python 3.12 reports on Linux only
|
|
943
|
+
if sys.version_info >= (3, 13):
|
|
944
|
+
count = os.process_cpu_count()
|
|
945
|
+
elif sys.platform == 'linux':
|
|
946
|
+
count = len(os.sched_getaffinity(0))
|
|
947
|
+
else:
|
|
948
|
+
count = os.cpu_count()
|
|
949
|
+
return count or 1
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
# The requires-python of the inline metadata of PEP 723, as its reference implementation reads it
|
|
953
|
+
def _requires_python(path: str) -> str | None:
|
|
954
|
+
try:
|
|
955
|
+
with open(path, encoding='utf-8', errors='replace') as f:
|
|
956
|
+
source = f.read()
|
|
957
|
+
except OSError:
|
|
958
|
+
return None
|
|
959
|
+
blocks = [m for m in RE_METADATA.finditer(source) if m.group('type') == 'script']
|
|
960
|
+
if not blocks:
|
|
961
|
+
return None
|
|
962
|
+
if len(blocks) > 1:
|
|
963
|
+
raise ValueError('Multiple script blocks found')
|
|
964
|
+
content = ''.join(
|
|
965
|
+
line[2:] if line.startswith('# ') else line[1:] for line in blocks[0].group('content').splitlines(keepends=True)
|
|
966
|
+
)
|
|
967
|
+
requires = tomllib.loads(content).get('requires-python')
|
|
968
|
+
if requires is not None and not isinstance(requires, str):
|
|
969
|
+
raise ValueError('requires-python is not a string')
|
|
970
|
+
return requires
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
# The version specifiers of PEP 440 for a release, without pre-, post- and development releases
|
|
974
|
+
def _satisfies(version: tuple[int, ...], specifier: str) -> bool:
|
|
975
|
+
satisfied = True
|
|
976
|
+
for clause in specifier.split(','):
|
|
977
|
+
if (m := RE_SPECIFIER.fullmatch(clause)) is None or (m.group(3) and m.group(1) not in ('==', '!=')):
|
|
978
|
+
raise ValueError(f'Invalid requires-python: {specifier}')
|
|
979
|
+
op, release, wildcard = m.groups()
|
|
980
|
+
want = tuple(int(n) for n in release.split('.'))
|
|
981
|
+
if wildcard:
|
|
982
|
+
satisfied = satisfied and (version[: len(want)] == want) == (op == '==')
|
|
983
|
+
elif op == '~=':
|
|
984
|
+
if len(want) < 2:
|
|
985
|
+
raise ValueError(f'Invalid requires-python: {specifier}')
|
|
986
|
+
satisfied = (
|
|
987
|
+
satisfied and _pad(version, want) >= _pad(want, version) and version[: len(want) - 1] == want[:-1]
|
|
988
|
+
)
|
|
989
|
+
else:
|
|
990
|
+
satisfied = satisfied and _OPERATORS[op](_pad(version, want), _pad(want, version))
|
|
991
|
+
return satisfied
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _pad(version: tuple[int, ...], other: tuple[int, ...]) -> tuple[int, ...]:
|
|
995
|
+
return version + (0,) * (len(other) - len(version))
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _name(path: str, width: int) -> str:
|
|
999
|
+
return f'{path} {"." * (width - len(path))}'
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _status_line(result: Result, width: int, color: bool = False) -> str:
|
|
1003
|
+
name = _name(result.path, width)
|
|
1004
|
+
if result.bail_out is not None:
|
|
1005
|
+
return f'{name} {_paint(f"Bailout called. Further testing stopped: {result.bail_out}", "red", color)}'
|
|
1006
|
+
if result.timed_out is not None:
|
|
1007
|
+
return f'{name} {_paint("Timed out", "red", color)}'
|
|
1008
|
+
if result.passed and result.skip_all is not None:
|
|
1009
|
+
return f'{name} {_paint(f"skipped: {result.skip_all}", "yellow", color)}'
|
|
1010
|
+
if result.passed:
|
|
1011
|
+
return f'{name} {_paint("ok", "green", color)}'
|
|
1012
|
+
if result.failed:
|
|
1013
|
+
return f'{name} {_paint(f"Failed {len(result.failed)}/{result.tests} subtests", "red", color)}'
|
|
1014
|
+
return f'{name} {_paint("Dubious", "red", color)}'
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
# A test point of the TAP output, at any level of subtests: one that failed is red, one with a directive yellow
|
|
1018
|
+
def _tap_line(line: str, color: bool) -> str:
|
|
1019
|
+
if color and (m := RE_TEST.match(line.lstrip())):
|
|
1020
|
+
if m.group(4):
|
|
1021
|
+
return _paint(line, 'yellow', color)
|
|
1022
|
+
if m.group(1):
|
|
1023
|
+
return _paint(line, 'red', color)
|
|
1024
|
+
return line
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
_COLORS = {'green': '32', 'red': '31', 'yellow': '33'}
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _paint(text: str, name: str, color: bool) -> str:
|
|
1031
|
+
return f'\033[{_COLORS[name]}m{text}\033[0m' if color else text
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
# Not in prove: NO_COLOR (https://no-color.org) and TERM=dumb, which turn off the colors of Python itself too
|
|
1035
|
+
def _color_default(stream: TextIO | None) -> bool:
|
|
1036
|
+
return _tty(stream) and not os.environ.get('NO_COLOR') and os.environ.get('TERM') != 'dumb'
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _tty(stream: TextIO | None) -> bool:
|
|
1040
|
+
try:
|
|
1041
|
+
return stream is not None and stream.isatty()
|
|
1042
|
+
except (OSError, ValueError):
|
|
1043
|
+
return False
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
# The width of the line of prove for the count of the test scripts that run at the same time
|
|
1047
|
+
_RULER_WIDTH = 72
|
|
1048
|
+
_RULER_TRAILER = '... )==='
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
class _Count:
|
|
1052
|
+
# The X/Y count of the tests of the test scripts that run, on a line that a carriage return renews, at most once a
|
|
1053
|
+
# second, as prove does: the count of the one test script that runs, or with jobs, a line of all of them
|
|
1054
|
+
def __init__(self, width: int, parallel: bool, color: bool) -> None:
|
|
1055
|
+
self.width = width
|
|
1056
|
+
self.parallel = parallel
|
|
1057
|
+
self.color = color
|
|
1058
|
+
self.active: dict[int, Result] = {}
|
|
1059
|
+
self.counted: dict[int, int] = {}
|
|
1060
|
+
self.tests = 0
|
|
1061
|
+
self.started = time.monotonic()
|
|
1062
|
+
self.second: int | None = None
|
|
1063
|
+
self.length = 0
|
|
1064
|
+
|
|
1065
|
+
def update(self, result: Result) -> None:
|
|
1066
|
+
key = id(result)
|
|
1067
|
+
if key not in self.active:
|
|
1068
|
+
self.active[key] = result
|
|
1069
|
+
self.counted[key] = 0
|
|
1070
|
+
if not self.parallel:
|
|
1071
|
+
self.second = None
|
|
1072
|
+
self._write(f'{_name(result.path, self.width)} ')
|
|
1073
|
+
else:
|
|
1074
|
+
self.show(refresh=True)
|
|
1075
|
+
return
|
|
1076
|
+
self.tests += result.tests - self.counted[key]
|
|
1077
|
+
self.counted[key] = result.tests
|
|
1078
|
+
self.show()
|
|
1079
|
+
|
|
1080
|
+
def finish(self, result: Result) -> None:
|
|
1081
|
+
self.active.pop(id(result), None)
|
|
1082
|
+
self.counted.pop(id(result), None)
|
|
1083
|
+
self.clear()
|
|
1084
|
+
|
|
1085
|
+
def show(self, refresh: bool = False) -> None:
|
|
1086
|
+
second = int(time.monotonic())
|
|
1087
|
+
if not self.active or (second == self.second and not refresh):
|
|
1088
|
+
return
|
|
1089
|
+
self.second = second
|
|
1090
|
+
if not self.parallel:
|
|
1091
|
+
result = next(iter(self.active.values()))
|
|
1092
|
+
name = f'{_name(result.path, self.width)} '
|
|
1093
|
+
count = f'{result.tests}/{result.planned or "?"} '
|
|
1094
|
+
self._write(f'{name}{_paint(count, "red", self.color and bool(result.failed))}', len(name) + len(count))
|
|
1095
|
+
return
|
|
1096
|
+
ruler = f'===( {self.tests:7d};{int(time.monotonic() - self.started)} '
|
|
1097
|
+
for r in self.active.values():
|
|
1098
|
+
planned = str(r.planned or '?')
|
|
1099
|
+
ruler += f'{r.tests:{len(planned)}d}/{planned} '
|
|
1100
|
+
ruler = f'{ruler[:-1]})==='
|
|
1101
|
+
if len(ruler) > _RULER_WIDTH:
|
|
1102
|
+
ruler = ruler[: _RULER_WIDTH - len(_RULER_TRAILER)] + _RULER_TRAILER
|
|
1103
|
+
self._write(ruler.ljust(_RULER_WIDTH, '='))
|
|
1104
|
+
|
|
1105
|
+
def clear(self) -> None:
|
|
1106
|
+
if self.length:
|
|
1107
|
+
sys.stdout.write(f'\r{" " * self.length}\r')
|
|
1108
|
+
sys.stdout.flush()
|
|
1109
|
+
self.length = 0
|
|
1110
|
+
|
|
1111
|
+
# The length is the one on the screen, without the escape sequences of the colors
|
|
1112
|
+
def _write(self, line: str, length: int | None = None) -> None:
|
|
1113
|
+
sys.stdout.write(f'\r{line}')
|
|
1114
|
+
sys.stdout.flush()
|
|
1115
|
+
self.length = len(line) if length is None else length
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
# The first half of the lines, and the last half in a queue that leaves out the lines before them
|
|
1119
|
+
async def _read_lines(stream: asyncio.StreamReader, result: Result, limit: int | None) -> None:
|
|
1120
|
+
head = None if limit is None else _head(limit)
|
|
1121
|
+
tail = collections.deque[str](maxlen=None if limit is None else limit // 2)
|
|
1122
|
+
try:
|
|
1123
|
+
while raw := await stream.readline():
|
|
1124
|
+
line = raw.decode('utf-8', 'replace').rstrip('\r\n')
|
|
1125
|
+
if head is None or len(result.stderr) < head:
|
|
1126
|
+
result.stderr.append(line)
|
|
1127
|
+
continue
|
|
1128
|
+
if len(tail) == tail.maxlen:
|
|
1129
|
+
result.stderr_left_out += 1
|
|
1130
|
+
tail.append(line)
|
|
1131
|
+
finally:
|
|
1132
|
+
result.stderr.extend(tail)
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def _head(limit: int) -> int:
|
|
1136
|
+
return (limit + 1) // 2
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
# Not in prove: the words of a command line of Windows, whose paths have backslashes, which a shell of POSIX takes for
|
|
1140
|
+
# escapes, so they are split as there, and the quotes around a word are removed
|
|
1141
|
+
def _split(line: str) -> list[str]:
|
|
1142
|
+
if sys.platform != 'win32':
|
|
1143
|
+
return shlex.split(line)
|
|
1144
|
+
return [re.sub(r'\A(["\'])(.*)\1\Z', r'\2', word, flags=re.S) for word in shlex.split(line, posix=False)]
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def _lines(value: str) -> int:
|
|
1148
|
+
if (lines := int(value)) < 0:
|
|
1149
|
+
raise argparse.ArgumentTypeError(f'invalid number of lines: {value}')
|
|
1150
|
+
return lines
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
# Not in prove: Windows has no process group that a signal kills, and a job, which the processes that the script starts
|
|
1154
|
+
# join, kills them with the script, also when the script has ended before them, as a process group does elsewhere
|
|
1155
|
+
if sys.platform == 'win32':
|
|
1156
|
+
import ctypes
|
|
1157
|
+
|
|
1158
|
+
from ctypes import wintypes
|
|
1159
|
+
|
|
1160
|
+
_kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
|
|
1161
|
+
_kernel32.CreateJobObjectW.argtypes = (wintypes.LPVOID, wintypes.LPCWSTR)
|
|
1162
|
+
_kernel32.CreateJobObjectW.restype = wintypes.HANDLE
|
|
1163
|
+
_kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
|
|
1164
|
+
_kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
1165
|
+
_kernel32.AssignProcessToJobObject.argtypes = (wintypes.HANDLE, wintypes.HANDLE)
|
|
1166
|
+
_kernel32.TerminateJobObject.argtypes = (wintypes.HANDLE, wintypes.UINT)
|
|
1167
|
+
_kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
|
|
1168
|
+
|
|
1169
|
+
# PROCESS_SET_QUOTA and PROCESS_TERMINATE, which AssignProcessToJobObject needs
|
|
1170
|
+
_ASSIGN = 0x0100 | 0x0001
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def _job(pid: int) -> int | None:
|
|
1174
|
+
if sys.platform != 'win32' or not (job := _kernel32.CreateJobObjectW(None, None)):
|
|
1175
|
+
return None
|
|
1176
|
+
process = _kernel32.OpenProcess(_ASSIGN, False, pid)
|
|
1177
|
+
assigned = bool(process) and bool(_kernel32.AssignProcessToJobObject(job, process))
|
|
1178
|
+
if process:
|
|
1179
|
+
_kernel32.CloseHandle(process)
|
|
1180
|
+
if not assigned:
|
|
1181
|
+
_kernel32.CloseHandle(job)
|
|
1182
|
+
return None
|
|
1183
|
+
return job
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def _close_job(job: int | None) -> None:
|
|
1187
|
+
if sys.platform == 'win32' and job is not None:
|
|
1188
|
+
_kernel32.CloseHandle(job)
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def _kill(process: asyncio.subprocess.Process, job: int | None) -> None:
|
|
1192
|
+
try:
|
|
1193
|
+
if sys.platform == 'win32':
|
|
1194
|
+
if job is None or not _kernel32.TerminateJobObject(job, 1):
|
|
1195
|
+
process.kill()
|
|
1196
|
+
elif hasattr(os, 'killpg'):
|
|
1197
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
1198
|
+
else:
|
|
1199
|
+
process.kill()
|
|
1200
|
+
except ProcessLookupError:
|
|
1201
|
+
pass
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
# The diagnostics of the failures in the standard error, which go to the test points that failed, as Pyjo.Test writes
|
|
1205
|
+
# them apart from the TAP output, with the ones of a subtest before the one of the test point of the subtest
|
|
1206
|
+
def _diagnostics(result: Result) -> None:
|
|
1207
|
+
failed = iter([point for point in result.points if point.failed])
|
|
1208
|
+
nested: list[str] = []
|
|
1209
|
+
current: list[str] | None = None
|
|
1210
|
+
for line in result.stderr:
|
|
1211
|
+
if RE_FAILED.match(line):
|
|
1212
|
+
current = point.diagnostics if (point := next(failed, None)) is not None else None
|
|
1213
|
+
if current is not None:
|
|
1214
|
+
current += [*nested, line]
|
|
1215
|
+
nested = []
|
|
1216
|
+
elif line[:1].isspace():
|
|
1217
|
+
nested.append(line)
|
|
1218
|
+
current = None
|
|
1219
|
+
elif current is not None and line.startswith('#') and not line.startswith('# Looks like'):
|
|
1220
|
+
current.append(line)
|
|
1221
|
+
else:
|
|
1222
|
+
current = None
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
# The lines of the diagnostics and of the standard error of a test script in the summary in Markdown, at most
|
|
1226
|
+
_DETAILS = 200
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
def _cell(text: str) -> str:
|
|
1230
|
+
# The text of a cell of a table of Markdown, with its pipes escaped
|
|
1231
|
+
return _html(text).replace('|', '\\|')
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def _html(text: str) -> str:
|
|
1235
|
+
return text.replace('&', '&').replace('<', '<').replace('>', '>')
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def _junit_child(parent: 'Pyjo.DOM.object', name: str, text: str | None, **attrs: str) -> None:
|
|
1239
|
+
# An element in the element of a fragment that new_tag has made
|
|
1240
|
+
tag = parent.children().first()
|
|
1241
|
+
assert tag is not None
|
|
1242
|
+
values = {key: _xml(value) for key, value in attrs.items()}
|
|
1243
|
+
tag.append_content(parent.new_tag(name, _xml(text), **values) if text else parent.new_tag(name, **values))
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _junit_line(case: 'Pyjo.DOM.object', diagnostics: list[str]) -> None:
|
|
1247
|
+
# The line of the first place of the diagnostics, which is the one of the assertion that failed in a subtest
|
|
1248
|
+
for line in diagnostics:
|
|
1249
|
+
if m := RE_AT.search(line):
|
|
1250
|
+
if (tag := case.children().first()) is not None:
|
|
1251
|
+
tag.attr(line=m.group(2))
|
|
1252
|
+
return
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
# What a test script did wrong as a whole, without the exit status of the number of its failures, which Pyjo.Test and
|
|
1256
|
+
# Test::More exit with, up to 254
|
|
1257
|
+
def _problems(result: Result) -> list[str]:
|
|
1258
|
+
problems = [f'Bail out! {result.bail_out}'] if result.bail_out is not None else []
|
|
1259
|
+
status = f'Non-zero exit status: {min(len(result.failed), 254)}' if result.failed else None
|
|
1260
|
+
return problems + [error for error in result.errors if error != status]
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def _point_name(point: Point) -> str:
|
|
1264
|
+
return f'{point.number} - {point.name}' if point.name else str(point.number)
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def _xml(text: str | None) -> str | None:
|
|
1268
|
+
# Text without the escapes of the terminal and the characters that XML 1.0 does not allow
|
|
1269
|
+
return RE_NOT_XML.sub('', RE_ANSI.sub('', text)) if text is not None else None
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def _summary(
|
|
1273
|
+
results: list[Result], elapsed: float, interrupted: bool = False, *, color: bool = False, err_color: bool = False
|
|
1274
|
+
) -> None:
|
|
1275
|
+
failed = [r for r in results if not r.passed]
|
|
1276
|
+
tests = sum(r.tests for r in results)
|
|
1277
|
+
|
|
1278
|
+
if interrupted:
|
|
1279
|
+
print(_paint('Test run interrupted!', 'red', err_color), file=sys.stderr, flush=True)
|
|
1280
|
+
|
|
1281
|
+
if failed:
|
|
1282
|
+
print('\nTest Summary Report\n-------------------')
|
|
1283
|
+
for r in failed:
|
|
1284
|
+
print(f'{r.path} (Tests: {r.tests} Failed: {len(r.failed)})')
|
|
1285
|
+
if r.failed:
|
|
1286
|
+
failures = f'Failed {"tests" if len(r.failed) > 1 else "test"}: {", ".join(map(str, r.failed))}'
|
|
1287
|
+
print(f' {_paint(failures, "red", color)}')
|
|
1288
|
+
if r.bail_out is not None:
|
|
1289
|
+
print(f' {_paint(f"Bail out: {r.bail_out}", "red", color)}')
|
|
1290
|
+
for error in r.errors:
|
|
1291
|
+
print(f' {_paint(error, "red", color)}')
|
|
1292
|
+
elif not interrupted:
|
|
1293
|
+
print(_paint('All tests successful.', 'green', color))
|
|
1294
|
+
|
|
1295
|
+
todo_passed = [r for r in results if r.todo_passed]
|
|
1296
|
+
for r in todo_passed:
|
|
1297
|
+
print(_paint(f'{r.path}: TODO passed: {", ".join(map(str, r.todo_passed))}', 'yellow', color))
|
|
1298
|
+
|
|
1299
|
+
print(f'Files={len(results)}, Tests={tests}, {elapsed:.0f} wallclock secs')
|
|
1300
|
+
status, name = ('FAIL', 'red') if failed or interrupted else ('PASS', 'green') if tests else ('NOTESTS', 'yellow')
|
|
1301
|
+
print(f'Result: {_paint(status, name, color)}', flush=True)
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
if __name__ == '__main__':
|
|
1305
|
+
sys.exit(main())
|