atex 0.13__py3-none-any.whl → 0.15__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.
- atex/__init__.py +1 -1
- atex/aggregator/__init__.py +8 -7
- atex/aggregator/json.py +45 -42
- atex/cli/__init__.py +12 -14
- atex/cli/testingfarm.py +6 -6
- atex/connection/__init__.py +19 -17
- atex/connection/podman.py +2 -13
- atex/connection/ssh.py +39 -43
- atex/executor/__init__.py +0 -3
- atex/executor/duration.py +1 -1
- atex/executor/executor.py +47 -33
- atex/executor/reporter.py +39 -27
- atex/executor/scripts.py +8 -8
- atex/executor/testcontrol.py +46 -34
- atex/fmf.py +28 -44
- atex/orchestrator/__init__.py +6 -5
- atex/orchestrator/adhoc.py +59 -60
- atex/orchestrator/contest.py +15 -11
- atex/provisioner/__init__.py +31 -22
- atex/provisioner/libvirt/libvirt.py +50 -44
- atex/provisioner/libvirt/locking.py +25 -23
- atex/provisioner/podman/podman.py +8 -8
- atex/provisioner/testingfarm/api.py +91 -77
- atex/provisioner/testingfarm/testingfarm.py +16 -12
- atex/util/__init__.py +23 -0
- atex/util/dedent.py +2 -2
- atex/util/named_mapping.py +3 -3
- atex/util/path.py +1 -1
- atex/util/subprocess.py +28 -22
- atex/util/threads.py +9 -9
- {atex-0.13.dist-info → atex-0.15.dist-info}/METADATA +1 -1
- atex-0.15.dist-info/RECORD +44 -0
- atex/util/log.py +0 -71
- atex-0.13.dist-info/RECORD +0 -45
- {atex-0.13.dist-info → atex-0.15.dist-info}/WHEEL +0 -0
- {atex-0.13.dist-info → atex-0.15.dist-info}/entry_points.txt +0 -0
- {atex-0.13.dist-info → atex-0.15.dist-info}/licenses/COPYING.txt +0 -0
atex/executor/__init__.py
CHANGED
|
@@ -2,21 +2,18 @@ class ExecutorError(Exception):
|
|
|
2
2
|
"""
|
|
3
3
|
Raised by class Executor.
|
|
4
4
|
"""
|
|
5
|
-
pass
|
|
6
5
|
|
|
7
6
|
|
|
8
7
|
class TestSetupError(ExecutorError):
|
|
9
8
|
"""
|
|
10
9
|
Raised when the preparation for test execution (ie. pkg install) fails.
|
|
11
10
|
"""
|
|
12
|
-
pass
|
|
13
11
|
|
|
14
12
|
|
|
15
13
|
class TestAbortedError(ExecutorError):
|
|
16
14
|
"""
|
|
17
15
|
Raised when an infrastructure-related issue happened while running a test.
|
|
18
16
|
"""
|
|
19
|
-
pass
|
|
20
17
|
|
|
21
18
|
|
|
22
19
|
from . import testcontrol # noqa: F401, E402
|
atex/executor/duration.py
CHANGED
|
@@ -10,7 +10,7 @@ class Duration:
|
|
|
10
10
|
|
|
11
11
|
def __init__(self, fmf_duration):
|
|
12
12
|
"""
|
|
13
|
-
|
|
13
|
+
- `fmf_duration` is the string specified as 'duration' in FMF metadata.
|
|
14
14
|
"""
|
|
15
15
|
duration = self._fmf_to_seconds(fmf_duration)
|
|
16
16
|
self.end = time.monotonic() + duration
|
atex/executor/executor.py
CHANGED
|
@@ -46,16 +46,18 @@ class Executor:
|
|
|
46
46
|
|
|
47
47
|
def __init__(self, fmf_tests, connection, *, env=None, state_dir=None):
|
|
48
48
|
"""
|
|
49
|
-
|
|
49
|
+
- `fmf_tests` is a class FMFTests instance with (discovered) tests.
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
- `connection` is a class Connection instance, already fully connected.
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
- `env` is a dict of extra environment variables to pass to the
|
|
54
|
+
plan prepare/finish scripts and to all tests.
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
- `state_dir` is a string or Path specifying path on the remote system
|
|
57
|
+
for storing additional data, such as tests, execution wrappers,
|
|
58
|
+
temporary plan-exported variables, etc.
|
|
59
|
+
|
|
60
|
+
If left as `None`, a tmpdir is used.
|
|
59
61
|
"""
|
|
60
62
|
self.lock = threading.RLock()
|
|
61
63
|
self.fmf_tests = fmf_tests
|
|
@@ -130,7 +132,7 @@ class Executor:
|
|
|
130
132
|
def upload_tests(self):
|
|
131
133
|
"""
|
|
132
134
|
Upload a directory of all tests, the location of which was provided to
|
|
133
|
-
__init__() inside
|
|
135
|
+
`__init__()` inside `fmf_tests`, to the remote host.
|
|
134
136
|
"""
|
|
135
137
|
self.conn.rsync(
|
|
136
138
|
"-r", "--delete", "--exclude=.git/",
|
|
@@ -199,16 +201,19 @@ class Executor:
|
|
|
199
201
|
"""
|
|
200
202
|
Run one test on the remote system.
|
|
201
203
|
|
|
202
|
-
|
|
204
|
+
- `test_name` is a string with test name.
|
|
205
|
+
|
|
206
|
+
- `output_dir` is a destination dir (string or Path) for results reported
|
|
207
|
+
and files uploaded by the test.
|
|
208
|
+
|
|
209
|
+
Results are always stored in a line-JSON format in a file named
|
|
210
|
+
`results`, files are always uploaded to directory named `files`,
|
|
211
|
+
both inside `output_dir`.
|
|
203
212
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
format in a file named 'results', files are always uploaded to directory
|
|
207
|
-
named 'files', both inside 'output_dir'.
|
|
208
|
-
The path for 'output_dir' must already exist and be an empty directory
|
|
209
|
-
(ie. typically a tmpdir).
|
|
213
|
+
The path for `output_dir` must already exist and be an empty directory
|
|
214
|
+
(ie. typically a tmpdir).
|
|
210
215
|
|
|
211
|
-
|
|
216
|
+
- `env` is a dict of extra environment variables to pass to the test.
|
|
212
217
|
|
|
213
218
|
Returns an integer exit code of the test script.
|
|
214
219
|
"""
|
|
@@ -280,22 +285,26 @@ class Executor:
|
|
|
280
285
|
abort("cancel requested")
|
|
281
286
|
|
|
282
287
|
if state == self.State.STARTING_TEST:
|
|
283
|
-
control_fd, pipe_w = os.pipe()
|
|
284
|
-
os.set_blocking(control_fd, False)
|
|
285
|
-
control.reassign(control_fd)
|
|
286
288
|
# reconnect/reboot count (for compatibility)
|
|
287
289
|
env_vars["TMT_REBOOT_COUNT"] = str(reconnects)
|
|
288
290
|
env_vars["TMT_TEST_RESTART_COUNT"] = str(reconnects)
|
|
289
|
-
# run the test in the background, letting it log output directly to
|
|
290
|
-
# an opened file (we don't handle it, cmd client sends it to kernel)
|
|
291
291
|
env_args = (f"{k}={v}" for k, v in env_vars.items())
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
292
|
+
try:
|
|
293
|
+
# open a pipe for test control
|
|
294
|
+
control_fd, pipe_w = os.pipe()
|
|
295
|
+
os.set_blocking(control_fd, False)
|
|
296
|
+
control.reassign(control_fd)
|
|
297
|
+
# run the test in the background, letting it log output directly to
|
|
298
|
+
# an opened file (we don't handle it, cmd client sends it to kernel)
|
|
299
|
+
with reporter.open_testout() as testout_fd:
|
|
300
|
+
test_proc = self.conn.cmd(
|
|
301
|
+
("env", *env_args, f"{self.work_dir}/wrapper.sh"),
|
|
302
|
+
stdout=pipe_w,
|
|
303
|
+
stderr=testout_fd,
|
|
304
|
+
func=util.subprocess_Popen,
|
|
305
|
+
)
|
|
306
|
+
finally:
|
|
307
|
+
os.close(pipe_w)
|
|
299
308
|
state = self.State.READING_CONTROL
|
|
300
309
|
|
|
301
310
|
elif state == self.State.READING_CONTROL:
|
|
@@ -304,7 +313,7 @@ class Executor:
|
|
|
304
313
|
abort(f"got exceptional condition on control_fd {control_fd}")
|
|
305
314
|
elif rlist:
|
|
306
315
|
control.process()
|
|
307
|
-
if control.eof:
|
|
316
|
+
if control.eof or control.disconnect_received:
|
|
308
317
|
os.close(control_fd)
|
|
309
318
|
control_fd = None
|
|
310
319
|
state = self.State.WAITING_FOR_EXIT
|
|
@@ -320,11 +329,16 @@ class Executor:
|
|
|
320
329
|
else:
|
|
321
330
|
# unexpected error happened (crash, disconnect, etc.)
|
|
322
331
|
self.conn.disconnect()
|
|
323
|
-
# if
|
|
324
|
-
if control.
|
|
332
|
+
# if there was a test control parser running
|
|
333
|
+
if control.in_progress:
|
|
334
|
+
abort(
|
|
335
|
+
f"{str(control.in_progress)} was running while test "
|
|
336
|
+
f"wrapper unexpectedly exited with {code}",
|
|
337
|
+
)
|
|
338
|
+
# if test control disconnect was intentional, try to reconnect
|
|
339
|
+
if control.disconnect_received:
|
|
325
340
|
state = self.State.RECONNECTING
|
|
326
|
-
|
|
327
|
-
control.reconnect = None
|
|
341
|
+
control.disconnect_received = False
|
|
328
342
|
else:
|
|
329
343
|
abort(
|
|
330
344
|
f"test wrapper unexpectedly exited with {code} and "
|
atex/executor/reporter.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import json
|
|
3
|
+
import contextlib
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
|
|
5
6
|
from .. import util
|
|
@@ -17,32 +18,30 @@ class Reporter:
|
|
|
17
18
|
|
|
18
19
|
def __init__(self, output_dir, results_file, files_dir):
|
|
19
20
|
"""
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
- `output_dir` is a destination dir (string or Path) for results
|
|
22
|
+
reported and files uploaded.
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
- `results_file` is a file name inside `output_dir` the results
|
|
25
|
+
will be reported into.
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
- `files_dir` is a dir name inside `output_dir` any files will be
|
|
28
|
+
uploaded to.
|
|
28
29
|
"""
|
|
29
|
-
output_dir = Path(output_dir)
|
|
30
|
-
self.
|
|
31
|
-
self.results_file = output_dir / results_file
|
|
32
|
-
self.files_dir = output_dir / files_dir
|
|
33
|
-
self.output_dir = output_dir
|
|
30
|
+
self.output_dir = Path(output_dir)
|
|
31
|
+
self.results_file = self.output_dir / results_file
|
|
34
32
|
self.results_fobj = None
|
|
35
|
-
self.
|
|
33
|
+
self.files_dir = self.output_dir / files_dir
|
|
34
|
+
self.testout_file = self.output_dir / self.TESTOUT
|
|
36
35
|
|
|
37
36
|
def start(self):
|
|
38
|
-
if self.testout_file.exists():
|
|
39
|
-
raise FileExistsError(f"{self.testout_file} already exists")
|
|
40
|
-
self.testout_fobj = open(self.testout_file, "wb")
|
|
41
|
-
|
|
42
37
|
if self.results_file.exists():
|
|
43
38
|
raise FileExistsError(f"{self.results_file} already exists")
|
|
44
39
|
self.results_fobj = open(self.results_file, "w", newline="\n")
|
|
45
40
|
|
|
41
|
+
if self.testout_file.exists():
|
|
42
|
+
raise FileExistsError(f"{self.testout_file} already exists")
|
|
43
|
+
self.testout_file.touch()
|
|
44
|
+
|
|
46
45
|
if self.files_dir.exists():
|
|
47
46
|
raise FileExistsError(f"{self.files_dir} already exists")
|
|
48
47
|
self.files_dir.mkdir()
|
|
@@ -51,11 +50,7 @@ class Reporter:
|
|
|
51
50
|
if self.results_fobj:
|
|
52
51
|
self.results_fobj.close()
|
|
53
52
|
self.results_fobj = None
|
|
54
|
-
|
|
55
|
-
if self.testout_fobj:
|
|
56
|
-
self.testout_fobj.close()
|
|
57
|
-
self.testout_fobj = None
|
|
58
|
-
Path(self.testout_file).unlink()
|
|
53
|
+
self.testout_file.unlink(missing_ok=True)
|
|
59
54
|
|
|
60
55
|
def __enter__(self):
|
|
61
56
|
try:
|
|
@@ -72,7 +67,7 @@ class Reporter:
|
|
|
72
67
|
"""
|
|
73
68
|
Persistently record a test result.
|
|
74
69
|
|
|
75
|
-
|
|
70
|
+
- `result_line` is a dict in the format specified by RESULTS.md.
|
|
76
71
|
"""
|
|
77
72
|
json.dump(result_line, self.results_fobj, indent=None)
|
|
78
73
|
self.results_fobj.write("\n")
|
|
@@ -85,15 +80,32 @@ class Reporter:
|
|
|
85
80
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
86
81
|
return file_path
|
|
87
82
|
|
|
88
|
-
|
|
83
|
+
@contextlib.contextmanager
|
|
84
|
+
def open_file(self, file_name, mode, result_name=None):
|
|
89
85
|
"""
|
|
90
|
-
Open a file named
|
|
91
|
-
|
|
86
|
+
Open a file named `file_name` in a directory relevant to `result_name`.
|
|
87
|
+
Yields an opened file descriptor (as integer) as a Context Manager.
|
|
92
88
|
|
|
93
|
-
If
|
|
89
|
+
If `result_name` (typically a subtest) is not given, open the file
|
|
94
90
|
for the test (name) itself.
|
|
95
91
|
"""
|
|
96
|
-
|
|
92
|
+
fd = os.open(self._dest_path(file_name, result_name), mode)
|
|
93
|
+
try:
|
|
94
|
+
yield fd
|
|
95
|
+
finally:
|
|
96
|
+
os.close(fd)
|
|
97
|
+
|
|
98
|
+
@contextlib.contextmanager
|
|
99
|
+
def open_testout(self):
|
|
100
|
+
"""
|
|
101
|
+
Open a file named after self.TESTOUT inside self.output_dir.
|
|
102
|
+
Yields an opened file descriptor (as integer) as a Context Manager.
|
|
103
|
+
"""
|
|
104
|
+
fd = os.open(self.testout_file, os.O_WRONLY | os.O_CREAT | os.O_APPEND)
|
|
105
|
+
try:
|
|
106
|
+
yield fd
|
|
107
|
+
finally:
|
|
108
|
+
os.close(fd)
|
|
97
109
|
|
|
98
110
|
def link_testout(self, file_name, result_name=None):
|
|
99
111
|
# TODO: docstring
|
atex/executor/scripts.py
CHANGED
|
@@ -34,12 +34,12 @@ def test_wrapper(*, test, tests_dir, test_exec):
|
|
|
34
34
|
is considered as test output and any unintended environment changes
|
|
35
35
|
will impact the test itself.
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
- `test` is a class Test instance.
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
- `test_dir` is a remote directory (repository) of all the tests,
|
|
40
|
+
a.k.a. FMF metadata root.
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
- `test_exec` is a remote path to the actual test to run.
|
|
43
43
|
"""
|
|
44
44
|
out = "#!/bin/bash\n"
|
|
45
45
|
|
|
@@ -104,13 +104,13 @@ def test_setup(*, test, wrapper_exec, test_exec, test_yaml, **kwargs):
|
|
|
104
104
|
scripts: a test script (contents of 'test' from FMF) and a wrapper script
|
|
105
105
|
to run the test script.
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
- `test` is a class Test instance.
|
|
108
108
|
|
|
109
|
-
|
|
109
|
+
- `wrapper_exec` is the remote path where the wrapper script should be put.
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
- `test_exec` is the remote path where the test script should be put.
|
|
112
112
|
|
|
113
|
-
Any
|
|
113
|
+
Any `kwargs` are passed to `test_wrapper()`.
|
|
114
114
|
"""
|
|
115
115
|
out = "#!/bin/bash\n"
|
|
116
116
|
|
atex/executor/testcontrol.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import os
|
|
2
|
-
import collections
|
|
3
2
|
import json
|
|
3
|
+
import logging
|
|
4
|
+
import collections
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
logger = logging.getLogger("atex.executor.testcontrol")
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
class BufferFullError(Exception):
|
|
@@ -12,21 +13,22 @@ class BufferFullError(Exception):
|
|
|
12
13
|
class NonblockLineReader:
|
|
13
14
|
"""
|
|
14
15
|
Kind of like io.BufferedReader but capable of reading from non-blocking
|
|
15
|
-
sources (both O_NONBLOCK sockets and os.set_blocking(False)
|
|
16
|
-
re-assembling full lines from (potentially) multiple
|
|
16
|
+
sources (both `O_NONBLOCK` sockets and `os.set_blocking(False)`
|
|
17
|
+
descriptors), re-assembling full lines from (potentially) multiple
|
|
18
|
+
`read()` calls.
|
|
17
19
|
|
|
18
20
|
It also takes a file descriptor (not a file-like object) and takes extra
|
|
19
21
|
care to read one-byte-at-a-time to not read (and buffer) more data from the
|
|
20
22
|
source descriptor, allowing it to be used for in-kernel move, such as via
|
|
21
|
-
os.sendfile() or os.splice()
|
|
23
|
+
`os.sendfile()` or `os.splice()`.
|
|
22
24
|
"""
|
|
23
25
|
|
|
24
26
|
def __init__(self, src, maxlen=4096):
|
|
25
27
|
"""
|
|
26
|
-
|
|
28
|
+
- `src` is an opened file descriptor (integer).
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
- `maxlen` is a maximum potential line length, incl. the newline
|
|
31
|
+
character - if reached, a BufferFullError is raised.
|
|
30
32
|
"""
|
|
31
33
|
self.src = src
|
|
32
34
|
self.eof = False
|
|
@@ -35,7 +37,7 @@ class NonblockLineReader:
|
|
|
35
37
|
|
|
36
38
|
def readline(self):
|
|
37
39
|
r"""
|
|
38
|
-
Read a line and return it, without the
|
|
40
|
+
Read a line and return it, without the `\n` terminating character,
|
|
39
41
|
clearing the internal buffer upon return.
|
|
40
42
|
|
|
41
43
|
Returns None if nothing could be read (BlockingIOError) or if EOF
|
|
@@ -77,7 +79,6 @@ class BadControlError(Exception):
|
|
|
77
79
|
such as invalid syntax, unknown control word, or bad or unexpected data for
|
|
78
80
|
any given control word.
|
|
79
81
|
"""
|
|
80
|
-
pass
|
|
81
82
|
|
|
82
83
|
|
|
83
84
|
class BadReportJSONError(BadControlError):
|
|
@@ -86,7 +87,6 @@ class BadReportJSONError(BadControlError):
|
|
|
86
87
|
the TEST_CONROL.md specification when passing JSON data to the 'result'
|
|
87
88
|
control word.
|
|
88
89
|
"""
|
|
89
|
-
pass
|
|
90
90
|
|
|
91
91
|
|
|
92
92
|
class TestControl:
|
|
@@ -97,12 +97,12 @@ class TestControl:
|
|
|
97
97
|
|
|
98
98
|
def __init__(self, *, reporter, duration, control_fd=None):
|
|
99
99
|
"""
|
|
100
|
-
|
|
100
|
+
- `control_fd` is a non-blocking file descriptor to be read.
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
- `reporter` is an instance of class Reporter all the results
|
|
103
|
+
and uploaded files will be written to.
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
- `duration` is a class Duration instance.
|
|
106
106
|
"""
|
|
107
107
|
self.reporter = reporter
|
|
108
108
|
self.duration = duration
|
|
@@ -116,7 +116,7 @@ class TestControl:
|
|
|
116
116
|
self.in_progress = None
|
|
117
117
|
self.partial_results = collections.defaultdict(dict)
|
|
118
118
|
self.exit_code = None
|
|
119
|
-
self.
|
|
119
|
+
self.disconnect_received = False
|
|
120
120
|
self.nameless_result_seen = False
|
|
121
121
|
|
|
122
122
|
def reassign(self, new_fd):
|
|
@@ -138,7 +138,7 @@ class TestControl:
|
|
|
138
138
|
Read from the control file descriptor and potentially perform any
|
|
139
139
|
appropriate action based on commands read from the test.
|
|
140
140
|
|
|
141
|
-
Returns True if there is more data expected, False otherwise
|
|
141
|
+
Returns `True` if there is more data expected, `False` otherwise
|
|
142
142
|
(when the control file descriptor reached EOF).
|
|
143
143
|
"""
|
|
144
144
|
# if a parser operation is in progress, continue calling it,
|
|
@@ -156,7 +156,7 @@ class TestControl:
|
|
|
156
156
|
except BufferFullError as e:
|
|
157
157
|
raise BadControlError(str(e)) from None
|
|
158
158
|
|
|
159
|
-
|
|
159
|
+
logger.debug(f"control line: {line} // eof: {self.stream.eof}")
|
|
160
160
|
|
|
161
161
|
if self.stream.eof:
|
|
162
162
|
self.eof = True
|
|
@@ -176,8 +176,10 @@ class TestControl:
|
|
|
176
176
|
parser = self._parser_duration(arg)
|
|
177
177
|
elif word == "exitcode":
|
|
178
178
|
parser = self._parser_exitcode(arg)
|
|
179
|
-
elif word == "
|
|
180
|
-
parser = self.
|
|
179
|
+
elif word == "disconnect":
|
|
180
|
+
parser = self._parser_disconnect(arg)
|
|
181
|
+
elif word == "noop":
|
|
182
|
+
parser = self._parser_noop(arg)
|
|
181
183
|
else:
|
|
182
184
|
raise BadControlError(f"unknown control word: {word}")
|
|
183
185
|
|
|
@@ -191,8 +193,8 @@ class TestControl:
|
|
|
191
193
|
@classmethod
|
|
192
194
|
def _merge(cls, dst, src):
|
|
193
195
|
"""
|
|
194
|
-
Merge a
|
|
195
|
-
TEST_CONTROL.md for
|
|
196
|
+
Merge a `src` dict into `dst`, using the rules described by
|
|
197
|
+
TEST_CONTROL.md for "Partial results".
|
|
196
198
|
"""
|
|
197
199
|
for key, value in src.items():
|
|
198
200
|
# delete existing if new value is None (JSON null)
|
|
@@ -240,7 +242,7 @@ class TestControl:
|
|
|
240
242
|
yield
|
|
241
243
|
continue
|
|
242
244
|
if chunk == b"":
|
|
243
|
-
raise BadControlError("EOF when reading data")
|
|
245
|
+
raise BadControlError(f"EOF when reading data, got so far: {json_data}")
|
|
244
246
|
json_data += chunk
|
|
245
247
|
json_length -= len(chunk)
|
|
246
248
|
yield
|
|
@@ -267,8 +269,7 @@ class TestControl:
|
|
|
267
269
|
except ValueError as e:
|
|
268
270
|
raise BadReportJSONError(f"file entry {file_name} length: {str(e)}") from None
|
|
269
271
|
|
|
270
|
-
|
|
271
|
-
try:
|
|
272
|
+
with self.reporter.open_file(file_name, os.O_WRONLY | os.O_CREAT, name) as fd:
|
|
272
273
|
# Linux can't do splice(2) on O_APPEND fds, so we open it above
|
|
273
274
|
# as O_WRONLY and just seek to the end, simulating append
|
|
274
275
|
os.lseek(fd, 0, os.SEEK_END)
|
|
@@ -290,8 +291,6 @@ class TestControl:
|
|
|
290
291
|
raise BadControlError("EOF when reading data")
|
|
291
292
|
file_length -= written
|
|
292
293
|
yield
|
|
293
|
-
finally:
|
|
294
|
-
os.close(fd)
|
|
295
294
|
|
|
296
295
|
# either store partial result + return,
|
|
297
296
|
# or load previous partial result and merge into it
|
|
@@ -326,6 +325,18 @@ class TestControl:
|
|
|
326
325
|
except FileExistsError:
|
|
327
326
|
raise BadReportJSONError(f"file '{testout}' already exists") from None
|
|
328
327
|
|
|
328
|
+
# deduplicate file names
|
|
329
|
+
# - appending to files (multiple identical file 'name' in one result, or
|
|
330
|
+
# using partial:true) can create large amounts of files:[] entries,
|
|
331
|
+
# so simply add up all their lengths and specify each file 'name' once
|
|
332
|
+
if files := result.get("files"):
|
|
333
|
+
counter = collections.Counter()
|
|
334
|
+
for entry in files:
|
|
335
|
+
counter[entry["name"]] += entry["length"]
|
|
336
|
+
result["files"] = tuple(
|
|
337
|
+
{"name": name, "length": length} for name, length in counter.items()
|
|
338
|
+
)
|
|
339
|
+
|
|
329
340
|
self.reporter.report(result)
|
|
330
341
|
|
|
331
342
|
def _parser_duration(self, arg):
|
|
@@ -359,13 +370,14 @@ class TestControl:
|
|
|
359
370
|
if False:
|
|
360
371
|
yield
|
|
361
372
|
|
|
362
|
-
def
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
373
|
+
def _parser_disconnect(self, _):
|
|
374
|
+
self.disconnect_received = True
|
|
375
|
+
# pretend to be a generator
|
|
376
|
+
if False:
|
|
377
|
+
yield
|
|
378
|
+
|
|
379
|
+
@staticmethod
|
|
380
|
+
def _parser_noop(_):
|
|
369
381
|
# pretend to be a generator
|
|
370
382
|
if False:
|
|
371
383
|
yield
|
atex/fmf.py
CHANGED
|
@@ -12,11 +12,15 @@ def listlike(data, key):
|
|
|
12
12
|
defined as a dict or a list.
|
|
13
13
|
|
|
14
14
|
This is needed because many fmf metadata keys can be used either as
|
|
15
|
+
|
|
15
16
|
some_key: 123
|
|
17
|
+
|
|
16
18
|
or as lists via YAML syntax
|
|
19
|
+
|
|
17
20
|
some_key:
|
|
18
21
|
- 123
|
|
19
22
|
- 456
|
|
23
|
+
|
|
20
24
|
and, for simplicity, we want to always deal with lists (iterables).
|
|
21
25
|
"""
|
|
22
26
|
if value := data.get(key):
|
|
@@ -39,32 +43,34 @@ class FMFTests:
|
|
|
39
43
|
context=None,
|
|
40
44
|
):
|
|
41
45
|
"""
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
- `fmf_tree` is filesystem path somewhere inside fmf metadata tree,
|
|
47
|
+
or a root fmf.Tree instance.
|
|
44
48
|
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
- `plan_name` is fmf identifier (like `/some/thing`) of a tmt plan
|
|
50
|
+
to use for discovering tests. If None, a dummy (empty) plan is used.
|
|
47
51
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
- `names`, `filters`, `conditions` and `exclude` (all tuple/list)
|
|
53
|
+
are fmf tree filters (resolved by the fmf module), overriding any
|
|
54
|
+
existing tree filters in the plan's discover phase specifies, where:
|
|
51
55
|
|
|
52
|
-
|
|
56
|
+
- `names` are test regexes like `["/some/test", "/another/test"]`.
|
|
53
57
|
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
- `filters` are fmf-style filter expressions, as documented on
|
|
59
|
+
https://fmf.readthedocs.io/en/stable/modules.html#fmf.filter
|
|
56
60
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
["environment['FOO'] == 'BAR'"]
|
|
61
|
-
["'enabled' not in locals() or enabled"]
|
|
62
|
-
Note that KeyError is silently ignored and treated as False.
|
|
61
|
+
- `conditions` are python expressions whose namespace `locals()`
|
|
62
|
+
are set up to be a dictionary of the fmf tree. When any of the
|
|
63
|
+
expressions returns `True`, the tree is returned, ie.
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
["environment['FOO'] == 'BAR'"]
|
|
66
|
+
["'enabled' not in locals() or enabled"]
|
|
65
67
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
Note that KeyError is silently ignored and treated as `False`.
|
|
69
|
+
|
|
70
|
+
- `excludes` are test regexes to exclude, format same as `names`.
|
|
71
|
+
|
|
72
|
+
- `context` is a dict like `{'distro': 'rhel-9.6'}` used for additional
|
|
73
|
+
adjustment of the discovered fmf metadata.
|
|
68
74
|
"""
|
|
69
75
|
# list of packages to install, as extracted from plan
|
|
70
76
|
self.prepare_pkgs = []
|
|
@@ -187,8 +193,8 @@ class FMFTests:
|
|
|
187
193
|
|
|
188
194
|
def test_pkg_requires(data, key="require"):
|
|
189
195
|
"""
|
|
190
|
-
Yield RPM package names specified by test
|
|
191
|
-
in the metadata
|
|
196
|
+
Yield RPM package names specified by test `data` (fmf metadata dict)
|
|
197
|
+
in the metadata `key` (require or recommend), ignoring any non-RPM-package
|
|
192
198
|
requires/recommends.
|
|
193
199
|
"""
|
|
194
200
|
for entry in listlike(data, key):
|
|
@@ -204,7 +210,7 @@ def test_pkg_requires(data, key="require"):
|
|
|
204
210
|
def all_pkg_requires(fmf_tests, key="require"):
|
|
205
211
|
"""
|
|
206
212
|
Yield RPM package names from the plan and all tests discovered by
|
|
207
|
-
a class FMFTests instance
|
|
213
|
+
a class FMFTests instance `fmf_tests`, ignoring any non-RPM-package
|
|
208
214
|
requires/recommends.
|
|
209
215
|
"""
|
|
210
216
|
# use a set to avoid duplicates
|
|
@@ -213,25 +219,3 @@ def all_pkg_requires(fmf_tests, key="require"):
|
|
|
213
219
|
for data in fmf_tests.tests.values():
|
|
214
220
|
pkgs.update(test_pkg_requires(data, key))
|
|
215
221
|
yield from pkgs
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
# Some extra notes for fmf.prune() arguments:
|
|
219
|
-
#
|
|
220
|
-
# Set 'names' to filter by a list of fmf node names, ie.
|
|
221
|
-
# ['/some/test', '/another/test']
|
|
222
|
-
#
|
|
223
|
-
# Set 'filters' to filter by a list of fmf-style filter expressions, see
|
|
224
|
-
# https://fmf.readthedocs.io/en/stable/modules.html#fmf.filter
|
|
225
|
-
#
|
|
226
|
-
# Set 'conditions' to filter by a list of python expressions whose namespace
|
|
227
|
-
# locals() are set up to be a dictionary of the tree. When any of the
|
|
228
|
-
# expressions returns True, the tree is returned, ie.
|
|
229
|
-
# ['environment["FOO"] == "BAR"']
|
|
230
|
-
# ['"enabled" not in locals() or enabled']
|
|
231
|
-
# Note that KeyError is silently ignored and treated as False.
|
|
232
|
-
#
|
|
233
|
-
# Set 'context' to a dictionary to post-process the tree metadata with
|
|
234
|
-
# adjust expressions (that may be present in a tree) using the specified
|
|
235
|
-
# context. Any other filters are applied afterwards to allow modification
|
|
236
|
-
# of tree metadata by the adjust expressions. Ie.
|
|
237
|
-
# {'distro': 'rhel-9.6.0', 'arch': 'x86_64'}
|