Pyjoyment 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. Pyjo/App/ASGI.py +735 -0
  2. Pyjo/App/CGI.py +773 -0
  3. Pyjo/App/FastCGI.py +631 -0
  4. Pyjo/App/Lambda.py +987 -0
  5. Pyjo/App/WSGI.py +427 -0
  6. Pyjo/App/__init__.py +409 -0
  7. Pyjo/Asset/File.py +377 -0
  8. Pyjo/Asset/Memory.py +235 -0
  9. Pyjo/Asset/__init__.py +200 -0
  10. Pyjo/Base.py +215 -0
  11. Pyjo/Cache.py +119 -0
  12. Pyjo/Collection.py +570 -0
  13. Pyjo/Content/MultiPart.py +325 -0
  14. Pyjo/Content/Single.py +193 -0
  15. Pyjo/Content/__init__.py +806 -0
  16. Pyjo/Cookie/Request.py +90 -0
  17. Pyjo/Cookie/Response.py +294 -0
  18. Pyjo/Cookie/__init__.py +143 -0
  19. Pyjo/DOM/CSS.py +1019 -0
  20. Pyjo/DOM/HTML.py +550 -0
  21. Pyjo/DOM/__init__.py +1329 -0
  22. Pyjo/Date.py +294 -0
  23. Pyjo/EventEmitter.py +522 -0
  24. Pyjo/Exception.py +494 -0
  25. Pyjo/File.py +959 -0
  26. Pyjo/GetOpt.py +605 -0
  27. Pyjo/Headers.py +760 -0
  28. Pyjo/HelloWorld.py +77 -0
  29. Pyjo/Home.py +109 -0
  30. Pyjo/IOLoop/ChildProcess.py +591 -0
  31. Pyjo/IOLoop/Client.py +736 -0
  32. Pyjo/IOLoop/Datagram.py +982 -0
  33. Pyjo/IOLoop/Messages.py +664 -0
  34. Pyjo/IOLoop/Server.py +458 -0
  35. Pyjo/IOLoop/Stdio.py +446 -0
  36. Pyjo/IOLoop/Stream.py +644 -0
  37. Pyjo/IOLoop/Subprocess.py +419 -0
  38. Pyjo/IOLoop/TLS.py +360 -0
  39. Pyjo/IOLoop/__init__.py +1036 -0
  40. Pyjo/IOLoop/certs/README.md +41 -0
  41. Pyjo/IOLoop/certs/ca.crt +20 -0
  42. Pyjo/IOLoop/certs/server.crt +21 -0
  43. Pyjo/IOLoop/certs/server.key +28 -0
  44. Pyjo/JSON/Pointer.py +135 -0
  45. Pyjo/JSON/RPC.py +772 -0
  46. Pyjo/JSON/Schema.py +2506 -0
  47. Pyjo/JSON/__init__.py +573 -0
  48. Pyjo/Loader.py +342 -0
  49. Pyjo/Log.py +723 -0
  50. Pyjo/Message/Request.py +725 -0
  51. Pyjo/Message/Response.py +476 -0
  52. Pyjo/Message/__init__.py +830 -0
  53. Pyjo/Parameters.py +472 -0
  54. Pyjo/Path.py +419 -0
  55. Pyjo/Promise.py +894 -0
  56. Pyjo/Prove.py +1305 -0
  57. Pyjo/Reactor/Asyncio.py +854 -0
  58. Pyjo/Reactor/Poll.py +439 -0
  59. Pyjo/Reactor/__init__.py +525 -0
  60. Pyjo/SSE.py +119 -0
  61. Pyjo/Server/ASGI.py +486 -0
  62. Pyjo/Server/CGI.py +259 -0
  63. Pyjo/Server/Daemon.py +1007 -0
  64. Pyjo/Server/FastCGI.py +517 -0
  65. Pyjo/Server/Hot/__init__.py +687 -0
  66. Pyjo/Server/Hot/__main__.py +5 -0
  67. Pyjo/Server/Lambda.py +641 -0
  68. Pyjo/Server/Prefork.py +917 -0
  69. Pyjo/Server/Prethread.py +394 -0
  70. Pyjo/Server/Reload/Backend/Poll.py +114 -0
  71. Pyjo/Server/Reload/Backend/__init__.py +126 -0
  72. Pyjo/Server/Reload/__init__.py +482 -0
  73. Pyjo/Server/Reload/__main__.py +5 -0
  74. Pyjo/Server/Stdio.py +228 -0
  75. Pyjo/Server/WSGI.py +349 -0
  76. Pyjo/Server/__init__.py +396 -0
  77. Pyjo/String/Bytes.py +532 -0
  78. Pyjo/String/HTML.py +210 -0
  79. Pyjo/String/Mixin.py +333 -0
  80. Pyjo/String/Template.py +470 -0
  81. Pyjo/String/Unicode.py +553 -0
  82. Pyjo/String/__init__.py +0 -0
  83. Pyjo/TOML.py +232 -0
  84. Pyjo/Template.py +1290 -0
  85. Pyjo/Test/Pyjo.py +1454 -0
  86. Pyjo/Test/__init__.py +1233 -0
  87. Pyjo/Test/pytest_plugin.py +305 -0
  88. Pyjo/Transaction/HTTP.py +374 -0
  89. Pyjo/Transaction/WebSocket.py +709 -0
  90. Pyjo/Transaction/__init__.py +429 -0
  91. Pyjo/URL/Template.py +459 -0
  92. Pyjo/URL/__init__.py +651 -0
  93. Pyjo/Upload.py +139 -0
  94. Pyjo/UserAgent/CookieJar.py +391 -0
  95. Pyjo/UserAgent/Proxy.py +138 -0
  96. Pyjo/UserAgent/Server.py +239 -0
  97. Pyjo/UserAgent/Transactor.py +766 -0
  98. Pyjo/UserAgent/__init__.py +1243 -0
  99. Pyjo/Util.py +3567 -0
  100. Pyjo/WebSocket.py +332 -0
  101. Pyjo/YAML.py +1167 -0
  102. Pyjo/__init__.py +441 -0
  103. Pyjo/__main__.py +35 -0
  104. Pyjo/py.typed +0 -0
  105. Pyjoyment/Command/Author/__init__.py +20 -0
  106. Pyjoyment/Command/Author/generate/__init__.py +106 -0
  107. Pyjoyment/Command/Author/generate/app.py +299 -0
  108. Pyjoyment/Command/Author/generate/dockerfile.py +281 -0
  109. Pyjoyment/Command/Author/generate/kubernetes.py +514 -0
  110. Pyjoyment/Command/Author/generate/lambda_zip.py +372 -0
  111. Pyjoyment/Command/Author/generate/lite_app.py +113 -0
  112. Pyjoyment/Command/Author/generate/plugin.py +267 -0
  113. Pyjoyment/Command/Author/generate/pyproject.py +123 -0
  114. Pyjoyment/Command/Author/inflate.py +101 -0
  115. Pyjoyment/Command/__init__.py +332 -0
  116. Pyjoyment/Command/asgi.py +92 -0
  117. Pyjoyment/Command/cgi.py +90 -0
  118. Pyjoyment/Command/daemon.py +160 -0
  119. Pyjoyment/Command/eval.py +128 -0
  120. Pyjoyment/Command/fastcgi.py +99 -0
  121. Pyjoyment/Command/get.py +305 -0
  122. Pyjoyment/Command/lambda.py +117 -0
  123. Pyjoyment/Command/prefork.py +181 -0
  124. Pyjoyment/Command/prethread.py +126 -0
  125. Pyjoyment/Command/routes.py +133 -0
  126. Pyjoyment/Command/stdio.py +92 -0
  127. Pyjoyment/Command/version.py +141 -0
  128. Pyjoyment/Command/wsgi.py +93 -0
  129. Pyjoyment/Commands.py +546 -0
  130. Pyjoyment/Controller.py +1307 -0
  131. Pyjoyment/HelloWorld.py +56 -0
  132. Pyjoyment/Lite.py +494 -0
  133. Pyjoyment/Plugin/AllowedHosts.py +205 -0
  134. Pyjoyment/Plugin/Config.py +248 -0
  135. Pyjoyment/Plugin/DefaultHelpers.py +1202 -0
  136. Pyjoyment/Plugin/EPLRenderer.py +134 -0
  137. Pyjoyment/Plugin/EPRenderer.py +212 -0
  138. Pyjoyment/Plugin/HeaderCondition.py +98 -0
  139. Pyjoyment/Plugin/Health.py +325 -0
  140. Pyjoyment/Plugin/JSONConfig.py +172 -0
  141. Pyjoyment/Plugin/Mount.py +223 -0
  142. Pyjoyment/Plugin/PyRenderer.py +209 -0
  143. Pyjoyment/Plugin/TOMLConfig.py +141 -0
  144. Pyjoyment/Plugin/TagHelpers.py +959 -0
  145. Pyjoyment/Plugin/YAMLConfig.py +178 -0
  146. Pyjoyment/Plugin/__init__.py +80 -0
  147. Pyjoyment/Plugins.py +215 -0
  148. Pyjoyment/Renderer.py +673 -0
  149. Pyjoyment/Routes/Match.py +251 -0
  150. Pyjoyment/Routes/Pattern.py +443 -0
  151. Pyjoyment/Routes/Route.py +827 -0
  152. Pyjoyment/Routes/__init__.py +419 -0
  153. Pyjoyment/Sessions.py +255 -0
  154. Pyjoyment/Static.py +421 -0
  155. Pyjoyment/Types.py +238 -0
  156. Pyjoyment/Validator/Validation.py +395 -0
  157. Pyjoyment/Validator/__init__.py +304 -0
  158. Pyjoyment/__init__.py +1083 -0
  159. Pyjoyment/__main__.py +5 -0
  160. Pyjoyment/py.typed +0 -0
  161. Pyjoyment/resources/public/favicon.ico +0 -0
  162. Pyjoyment/resources/public/favicon.svg +345 -0
  163. Pyjoyment/resources/public/pyjo/bootstrap/LICENSE +21 -0
  164. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.bundle.min.js.map +1 -0
  165. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.css +6 -0
  166. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.js +7 -0
  167. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.min.css.map +1 -0
  168. Pyjoyment/resources/public/pyjo/failunicat.png +0 -0
  169. Pyjoyment/resources/public/pyjo/failunicat.svg +50 -0
  170. Pyjoyment/resources/public/pyjo/highlight.js/LICENSE +29 -0
  171. Pyjoyment/resources/public/pyjo/highlight.js/highlight-mojo-dark.css +56 -0
  172. Pyjoyment/resources/public/pyjo/highlight.js/highlight.min.js +1262 -0
  173. Pyjoyment/resources/public/pyjo/highlight.js/pyjoyment.min.js +1 -0
  174. Pyjoyment/resources/public/pyjo/logo-black-4x.png +0 -0
  175. Pyjoyment/resources/public/pyjo/logo-black-4x.svg +346 -0
  176. Pyjoyment/resources/public/pyjo/logo-white-2x.png +0 -0
  177. Pyjoyment/resources/public/pyjo/logo-white-2x.svg +346 -0
  178. Pyjoyment/resources/public/pyjo/logo-white-4x.png +0 -0
  179. Pyjoyment/resources/public/pyjo/logo-white-4x.svg +346 -0
  180. Pyjoyment/resources/public/pyjo/logo-white.png +0 -0
  181. Pyjoyment/resources/public/pyjo/logo-white.svg +346 -0
  182. Pyjoyment/resources/public/pyjo/logo.png +0 -0
  183. Pyjoyment/resources/public/pyjo/logo.svg +346 -0
  184. Pyjoyment/resources/public/pyjo/notfound.png +0 -0
  185. Pyjoyment/resources/public/pyjo/notfound.svg +3 -0
  186. Pyjoyment/resources/public/pyjo/nounicat.png +0 -0
  187. Pyjoyment/resources/public/pyjo/nounicat.svg +5 -0
  188. Pyjoyment/resources/public/pyjo/pinstripe-dark.png +0 -0
  189. Pyjoyment/resources/public/pyjo/pinstripe-dark.svg +4 -0
  190. Pyjoyment/resources/public/pyjo/pinstripe-light.png +0 -0
  191. Pyjoyment/resources/public/pyjo/pinstripe-light.svg +4 -0
  192. Pyjoyment/resources/public/pyjo/pyjo.css +204 -0
  193. Pyjoyment/resources/templates/pyjo/debug.html.ep +346 -0
  194. Pyjoyment/resources/templates/pyjo/exception.html.ep +32 -0
  195. Pyjoyment/resources/templates/pyjo/not_found.html.ep +45 -0
  196. pyjoyment-0.1.0.dist-info/METADATA +376 -0
  197. pyjoyment-0.1.0.dist-info/RECORD +200 -0
  198. pyjoyment-0.1.0.dist-info/WHEEL +4 -0
  199. pyjoyment-0.1.0.dist-info/entry_points.txt +8 -0
  200. pyjoyment-0.1.0.dist-info/licenses/LICENSE +201 -0
Pyjo/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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
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())