atex 0.7__py3-none-any.whl → 0.9__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 (48) hide show
  1. atex/cli/fmf.py +143 -0
  2. atex/cli/libvirt.py +127 -0
  3. atex/cli/testingfarm.py +35 -13
  4. atex/connection/__init__.py +13 -19
  5. atex/connection/podman.py +63 -0
  6. atex/connection/ssh.py +34 -52
  7. atex/executor/__init__.py +2 -0
  8. atex/executor/duration.py +60 -0
  9. atex/executor/executor.py +402 -0
  10. atex/executor/reporter.py +101 -0
  11. atex/{minitmt → executor}/scripts.py +37 -25
  12. atex/{minitmt → executor}/testcontrol.py +54 -42
  13. atex/fmf.py +237 -0
  14. atex/orchestrator/__init__.py +3 -59
  15. atex/orchestrator/aggregator.py +82 -134
  16. atex/orchestrator/orchestrator.py +385 -0
  17. atex/provision/__init__.py +74 -105
  18. atex/provision/libvirt/__init__.py +2 -24
  19. atex/provision/libvirt/libvirt.py +465 -0
  20. atex/provision/libvirt/locking.py +168 -0
  21. atex/provision/libvirt/setup-libvirt.sh +21 -1
  22. atex/provision/podman/__init__.py +1 -0
  23. atex/provision/podman/podman.py +274 -0
  24. atex/provision/testingfarm/__init__.py +2 -29
  25. atex/provision/testingfarm/api.py +123 -65
  26. atex/provision/testingfarm/testingfarm.py +234 -0
  27. atex/util/__init__.py +1 -6
  28. atex/util/libvirt.py +18 -0
  29. atex/util/log.py +31 -8
  30. atex/util/named_mapping.py +158 -0
  31. atex/util/path.py +16 -0
  32. atex/util/ssh_keygen.py +14 -0
  33. atex/util/threads.py +99 -0
  34. atex-0.9.dist-info/METADATA +178 -0
  35. atex-0.9.dist-info/RECORD +43 -0
  36. atex/cli/minitmt.py +0 -175
  37. atex/minitmt/__init__.py +0 -23
  38. atex/minitmt/executor.py +0 -348
  39. atex/minitmt/fmf.py +0 -202
  40. atex/provision/nspawn/README +0 -74
  41. atex/provision/podman/README +0 -59
  42. atex/provision/podman/host_container.sh +0 -74
  43. atex/provision/testingfarm/foo.py +0 -1
  44. atex-0.7.dist-info/METADATA +0 -102
  45. atex-0.7.dist-info/RECORD +0 -32
  46. {atex-0.7.dist-info → atex-0.9.dist-info}/WHEEL +0 -0
  47. {atex-0.7.dist-info → atex-0.9.dist-info}/entry_points.txt +0 -0
  48. {atex-0.7.dist-info → atex-0.9.dist-info}/licenses/COPYING.txt +0 -0
@@ -0,0 +1,101 @@
1
+ import os
2
+ import json
3
+ from pathlib import Path
4
+
5
+ from .. import util
6
+
7
+
8
+ class Reporter:
9
+ """
10
+ Collects reported results (in a format specified by RESULTS.md) for
11
+ a specific test, storing them persistently.
12
+ """
13
+
14
+ # internal name, stored inside 'output_dir' and hardlinked to
15
+ # 'testout'-JSON-key-specified result entries; deleted on exit
16
+ TESTOUT = "testout.temp"
17
+
18
+ def __init__(self, output_dir, results_file, files_dir):
19
+ """
20
+ 'output_dir' is a destination dir (string or Path) for results reported
21
+ and files uploaded.
22
+
23
+ 'results_file' is a file name inside 'output_dir' the results will be
24
+ reported into.
25
+
26
+ 'files_dir' is a dir name inside 'output_dir' any files will be
27
+ uploaded to.
28
+ """
29
+ output_dir = Path(output_dir)
30
+ self.testout_file = output_dir / self.TESTOUT
31
+ self.results_file = output_dir / results_file
32
+ self.files_dir = output_dir / files_dir
33
+ self.output_dir = output_dir
34
+ self.results_fobj = None
35
+ self.testout_fobj = None
36
+
37
+ 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
+ if self.results_file.exists():
43
+ raise FileExistsError(f"{self.results_file} already exists")
44
+ self.results_fobj = open(self.results_file, "w", newline="\n")
45
+
46
+ if self.files_dir.exists():
47
+ raise FileExistsError(f"{self.files_dir} already exists")
48
+ self.files_dir.mkdir()
49
+
50
+ def stop(self):
51
+ if self.results_fobj:
52
+ self.results_fobj.close()
53
+ 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()
59
+
60
+ def __enter__(self):
61
+ try:
62
+ self.start()
63
+ return self
64
+ except Exception:
65
+ self.stop()
66
+ raise
67
+
68
+ def __exit__(self, exc_type, exc_value, traceback):
69
+ self.stop()
70
+
71
+ def report(self, result_line):
72
+ """
73
+ Persistently record a test result.
74
+
75
+ 'result_line' is a dict in the format specified by RESULTS.md.
76
+ """
77
+ json.dump(result_line, self.results_fobj, indent=None)
78
+ self.results_fobj.write("\n")
79
+ self.results_fobj.flush()
80
+
81
+ def _dest_path(self, file_name, result_name=None):
82
+ result_name = util.normalize_path(result_name) if result_name else "."
83
+ # /path/to/files_dir / path/to/subtest / path/to/file.log
84
+ file_path = self.files_dir / result_name / util.normalize_path(file_name)
85
+ file_path.parent.mkdir(parents=True, exist_ok=True)
86
+ return file_path
87
+
88
+ def open_file(self, file_name, result_name=None, mode="wb"):
89
+ """
90
+ Open a file named 'file_name' in a directory relevant to 'result_name'.
91
+ Returns an opened file-like object that can be used in a context manager
92
+ just like with regular open().
93
+
94
+ If 'result_name' (typically a subtest) is not given, open the file
95
+ for the test (name) itself.
96
+ """
97
+ return open(self._dest_path(file_name, result_name), mode)
98
+
99
+ def link_testout(self, file_name, result_name=None):
100
+ # TODO: docstring
101
+ os.link(self.testout_file, self._dest_path(file_name, result_name))
@@ -1,8 +1,15 @@
1
+ import collections
2
+ import yaml
1
3
  from pathlib import Path
2
4
 
3
- from .. import util
5
+ from .. import util, fmf
6
+
7
+ # name: fmf path to the test as string, ie. /some/test
8
+ # data: dict of the parsed fmf metadata (ie. {'tag': ... , 'environment': ...})
9
+ # dir: relative pathlib.Path of the test .fmf to repo root, ie. some/test
10
+ # (may be different from name for "virtual" tests that share the same dir)
11
+ Test = collections.namedtuple("Test", ["name", "data", "dir"])
4
12
 
5
- from . import fmf
6
13
 
7
14
  # NOTE that we split test execution into 3 scripts:
8
15
  # - "setup script" (package installs, etc.)
@@ -17,8 +24,7 @@ from . import fmf
17
24
  # passed by an argument to 'ssh', leaving stdin/out/err untouched,
18
25
  # allowing the user to interact with it (if run interactively)
19
26
 
20
-
21
- def test_wrapper(*, test, tests_dir, test_exec, debug=False):
27
+ def test_wrapper(*, test, tests_dir, test_exec):
22
28
  """
23
29
  Generate a bash script that runs a user-specified test, preparing
24
30
  a test control channel for it, and reporting its exit code.
@@ -26,14 +32,12 @@ def test_wrapper(*, test, tests_dir, test_exec, debug=False):
26
32
  is considered as test output and any unintended environment changes
27
33
  will impact the test itself.
28
34
 
29
- 'test' is a atex.minitmt.fmf.FMFTest instance.
35
+ 'test' is a class Test instance.
30
36
 
31
37
  'test_dir' is a remote directory (repository) of all the tests,
32
38
  a.k.a. FMF metadata root.
33
39
 
34
40
  'test_exec' is a remote path to the actual test to run.
35
-
36
- 'debug' specifies whether to include wrapper output inside test output.
37
41
  """
38
42
  out = "#!/bin/bash\n"
39
43
 
@@ -46,7 +50,7 @@ def test_wrapper(*, test, tests_dir, test_exec, debug=False):
46
50
  # doing it here avoids unnecessary traffic (reading stdin) via ssh,
47
51
  # even if it is fed from subprocess.DEVNULL on the runner
48
52
 
49
- if debug:
53
+ if util.in_debug_mode():
50
54
  out += "set -x\n"
51
55
 
52
56
  # use a subshell to limit the scope of the CWD change
@@ -88,7 +92,18 @@ def test_wrapper(*, test, tests_dir, test_exec, debug=False):
88
92
  return out
89
93
 
90
94
 
91
- def test_setup(*, test, wrapper_exec, test_exec, debug=False, **kwargs):
95
+ def _install_packages(pkgs, extra_opts=None):
96
+ pkgs_str = " ".join(pkgs)
97
+ extra_opts = extra_opts or ()
98
+ dnf = ["dnf", "-y", "--setopt=install_weak_deps=False", "install", *extra_opts]
99
+ dnf_str = " ".join(dnf)
100
+ return util.dedent(fr"""
101
+ not_installed=$(rpm -q --qf '' {pkgs_str} | sed -nr 's/^package ([^ ]+) is not installed$/\1/p')
102
+ [[ $not_installed ]] && {dnf_str} $not_installed
103
+ """) # noqa: E501
104
+
105
+
106
+ def test_setup(*, test, wrapper_exec, test_exec, test_yaml, **kwargs):
92
107
  """
93
108
  Generate a bash script that should prepare the remote end for test
94
109
  execution.
@@ -97,42 +112,39 @@ def test_setup(*, test, wrapper_exec, test_exec, debug=False, **kwargs):
97
112
  scripts: a test script (contents of 'test' from FMF) and a wrapper script
98
113
  to run the test script.
99
114
 
115
+ 'test' is a class Test instance.
116
+
100
117
  'wrapper_exec' is the remote path where the wrapper script should be put.
101
118
 
102
119
  'test_exec' is the remote path where the test script should be put.
103
120
 
104
- 'test' is a atex.minitmt.fmf.FMFTest instance.
105
-
106
- 'debug' specifies whether to make the setup script extra verbose.
107
-
108
121
  Any 'kwargs' are passed to test_wrapper().
109
122
  """
110
123
  out = "#!/bin/bash\n"
111
124
 
112
- # have deterministic stdin, avoid leaking parent console
113
- # also avoid any accidental stdout output, we use it for wrapper path
114
- if debug:
115
- out += "exec {orig_stdout}>&1 1>&2\n"
125
+ if util.in_debug_mode():
116
126
  out += "set -xe\n"
117
127
  else:
118
- out += "exec {orig_stdout}>&1 2>/dev/null 1>&2\n"
128
+ out += "exec 1>/dev/null\n"
119
129
  out += "set -e\n"
120
130
 
121
131
  # install test dependencies
122
132
  # - only strings (package names) in require/recommend are supported
123
- if require := [x for x in fmf.listlike(test.data, "require") if isinstance(x, str)]:
124
- out += "dnf -y --setopt=install_weak_deps=False install "
125
- out += " ".join(f"'{pkg}'" for pkg in require) + "\n"
126
- if recommend := [x for x in fmf.listlike(test.data, "recommend") if isinstance(x, str)]:
127
- out += "dnf -y --setopt=install_weak_deps=False install --skip-broken "
128
- out += " ".join(f"'{pkg}'" for pkg in recommend) + "\n"
133
+ if require := list(fmf.test_pkg_requires(test.data, "require")):
134
+ out += _install_packages(require) + "\n"
135
+ if recommend := list(fmf.test_pkg_requires(test.data, "recommend")):
136
+ out += _install_packages(recommend, ("--skip-broken",)) + "\n"
137
+
138
+ # write out test data
139
+ out += f"cat > '{test_yaml}' <<'ATEX_SETUP_EOF'\n"
140
+ out += yaml.dump(test.data).rstrip("\n") # don't rely on trailing \n
141
+ out += "\nATEX_SETUP_EOF\n"
129
142
 
130
143
  # make the wrapper script
131
144
  out += f"cat > '{wrapper_exec}' <<'ATEX_SETUP_EOF'\n"
132
145
  out += test_wrapper(
133
146
  test=test,
134
147
  test_exec=test_exec,
135
- debug=debug,
136
148
  **kwargs,
137
149
  )
138
150
  out += "ATEX_SETUP_EOF\n"
@@ -95,30 +95,43 @@ class TestControl:
95
95
  processing test-issued commands, results and uploaded files.
96
96
  """
97
97
 
98
- def __init__(self, *, control_fd, aggregator, duration, testout_fd):
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
- 'aggregator' is an instance of a result aggregator (ie. CSVAggregator)
103
- all the results and uploaded files will be written to.
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
-
107
- 'testout_fd' is an optional file descriptor handle which the test uses
108
- to write its output to - useful here for the 'result' control word and
109
- its protocol, which allows "hardlinking" the fd to a real file name.
110
106
  """
111
- self.control_fd = control_fd
112
- self.stream = NonblockLineReader(control_fd)
113
- self.aggregator = aggregator
107
+ self.reporter = reporter
114
108
  self.duration = duration
115
- self.testout_fd = testout_fd
109
+ if control_fd:
110
+ self.control_fd = control_fd
111
+ self.stream = NonblockLineReader(control_fd)
112
+ else:
113
+ self.control_fd = None
114
+ self.stream = None
116
115
  self.eof = False
117
116
  self.in_progress = None
118
117
  self.partial_results = collections.defaultdict(dict)
119
- self.result_seen = False
120
118
  self.exit_code = None
121
119
  self.reconnect = None
120
+ self.nameless_result_seen = False
121
+
122
+ def reassign(self, new_fd):
123
+ """
124
+ Assign a new control file descriptor to read test control from,
125
+ replacing a previous one. Useful on test reconnect.
126
+ """
127
+ err = "tried to assign new control fd while"
128
+ if self.in_progress:
129
+ raise BadControlError(f"{err} old one is reading non-control binary data")
130
+ elif self.stream and self.stream.bytes_read != 0:
131
+ raise BadControlError(f"{err} old one is in the middle of reading a control line")
132
+ self.eof = False
133
+ self.control_fd = new_fd
134
+ self.stream = NonblockLineReader(new_fd)
122
135
 
123
136
  def process(self):
124
137
  """
@@ -143,7 +156,7 @@ class TestControl:
143
156
  except BufferFullError as e:
144
157
  raise BadControlError(str(e)) from None
145
158
 
146
- util.debug(f"got control line: {line}")
159
+ util.debug(f"got control line: {line} // eof: {self.stream.eof}")
147
160
 
148
161
  if self.stream.eof:
149
162
  self.eof = True
@@ -238,9 +251,10 @@ class TestControl:
238
251
  except json.decoder.JSONDecodeError as e:
239
252
  raise BadReportJSONError(f"JSON decode: {str(e)} caused by: {json_data}") from None
240
253
 
254
+ # note that this may be None (result for the test itself)
241
255
  name = result.get("name")
242
256
  if not name:
243
- raise BadReportJSONError("'name' not specified, but mandatory")
257
+ self.nameless_result_seen = True
244
258
 
245
259
  # upload files
246
260
  for entry in result.get("files", ()):
@@ -253,30 +267,28 @@ class TestControl:
253
267
  except ValueError as e:
254
268
  raise BadReportJSONError(f"file entry {file_name} length: {str(e)}") from None
255
269
 
256
- with self.aggregator.open_tmpfile() as fd:
257
- while file_length > 0:
258
- try:
259
- # try a more universal sendfile first, fall back to splice
270
+ try:
271
+ with self.reporter.open_file(file_name, name) as f:
272
+ fd = f.fileno()
273
+ while file_length > 0:
260
274
  try:
261
- written = os.sendfile(fd, self.control_fd, None, file_length)
262
- except OSError as e:
263
- if e.errno == 22: # EINVAL
264
- written = os.splice(self.control_fd, fd, file_length)
265
- else:
266
- raise
267
- except BlockingIOError:
275
+ # try a more universal sendfile first, fall back to splice
276
+ try:
277
+ written = os.sendfile(fd, self.control_fd, None, file_length)
278
+ except OSError as e:
279
+ if e.errno == 22: # EINVAL
280
+ written = os.splice(self.control_fd, fd, file_length)
281
+ else:
282
+ raise
283
+ except BlockingIOError:
284
+ yield
285
+ continue
286
+ if written == 0:
287
+ raise BadControlError("EOF when reading data")
288
+ file_length -= written
268
289
  yield
269
- continue
270
- if written == 0:
271
- raise BadControlError("EOF when reading data")
272
- file_length -= written
273
- yield
274
- try:
275
- self.aggregator.link_tmpfile_to(name, file_name, fd)
276
- except FileExistsError:
277
- raise BadReportJSONError(
278
- f"file '{file_name}' for '{name}' already exists",
279
- ) from None
290
+ except FileExistsError:
291
+ raise BadReportJSONError(f"file '{file_name}' already exists") from None
280
292
 
281
293
  # either store partial result + return,
282
294
  # or load previous partial result and merge into it
@@ -284,6 +296,8 @@ class TestControl:
284
296
  if partial:
285
297
  # do not store the 'partial' key in the result
286
298
  del result["partial"]
299
+ # note that nameless result will get None as dict key,
300
+ # which is perfectly fine
287
301
  self._merge(self.partial_results[name], result)
288
302
  # partial = do nothing
289
303
  return
@@ -295,7 +309,7 @@ class TestControl:
295
309
  if name in self.partial_results:
296
310
  partial_result = self.partial_results[name]
297
311
  del self.partial_results[name]
298
- self._nested_merge(partial_result, result)
312
+ self._merge(partial_result, result)
299
313
  result = partial_result
300
314
 
301
315
  if "testout" in result:
@@ -303,13 +317,11 @@ class TestControl:
303
317
  if not testout:
304
318
  raise BadReportJSONError("'testout' specified, but empty")
305
319
  try:
306
- self.aggregator.link_tmpfile_to(name, testout, self.testout_fd)
320
+ self.reporter.link_testout(testout, name)
307
321
  except FileExistsError:
308
- raise BadReportJSONError(f"file '{testout}' for '{name}' already exists") from None
309
-
310
- self.aggregator.report(result)
322
+ raise BadReportJSONError(f"file '{testout}' already exists") from None
311
323
 
312
- self.result_seen = True
324
+ self.reporter.report(result)
313
325
 
314
326
  def _parser_duration(self, arg):
315
327
  if not arg:
atex/fmf.py ADDED
@@ -0,0 +1,237 @@
1
+ import re
2
+ import collections
3
+ from pathlib import Path
4
+
5
+ # from system-wide sys.path
6
+ import fmf
7
+
8
+
9
+ def listlike(data, key):
10
+ """
11
+ Get a piece of fmf metadata as an iterable regardless of whether it was
12
+ defined as a dict or a list.
13
+
14
+ This is needed because many fmf metadata keys can be used either as
15
+ some_key: 123
16
+ or as lists via YAML syntax
17
+ some_key:
18
+ - 123
19
+ - 456
20
+ and, for simplicity, we want to always deal with lists (iterables).
21
+ """
22
+ if value := data.get(key):
23
+ return value if isinstance(value, list) else (value,)
24
+ else:
25
+ return ()
26
+
27
+
28
+ class FMFTests:
29
+ """
30
+ FMF test metadata parsed from on-disk metadata using a specific plan name,
31
+ with all metadata dictionaries for all nodes being adjusted by that plan
32
+ and (optionally) a specified context.
33
+ """
34
+ # TODO: usage example ^^^^
35
+
36
+ def __init__(
37
+ self, fmf_tree, plan_name=None, *,
38
+ names=None, filters=None, conditions=None, excludes=None,
39
+ context=None,
40
+ ):
41
+ """
42
+ 'fmf_tree' is filesystem path somewhere inside fmf metadata tree,
43
+ or a root fmf.Tree instance.
44
+
45
+ 'plan_name' is fmf identifier (like /some/thing) of a tmt plan
46
+ to use for discovering tests. If None, a dummy (empty) plan is used.
47
+
48
+ 'names', 'filters', 'conditions' and 'exclude' (all tuple/list)
49
+ are fmf tree filters (resolved by the fmf module), overriding any
50
+ existing tree filters in the plan's discover phase specifies, where:
51
+
52
+ 'names' are test regexes like ["/some/test", "/another/test"]
53
+
54
+ 'filters' are fmf-style filter expressions, as documented on
55
+ https://fmf.readthedocs.io/en/stable/modules.html#fmf.filter
56
+
57
+ 'conditions' are python expressions whose namespace locals()
58
+ are set up to be a dictionary of the fmf tree. When any of the
59
+ expressions returns True, the tree is returned, ie.
60
+ ["environment['FOO'] == 'BAR'"]
61
+ ["'enabled' not in locals() or enabled"]
62
+ Note that KeyError is silently ignored and treated as False.
63
+
64
+ 'excludes' are test regexes to exclude, format same as 'names'
65
+
66
+ 'context' is a dict like {'distro': 'rhel-9.6'} used for additional
67
+ adjustment of the discovered fmf metadata.
68
+ """
69
+ # list of packages to install, as extracted from plan
70
+ self.prepare_pkgs = []
71
+ # list of scripts to run, as extracted from plan
72
+ self.prepare_scripts = []
73
+ self.finish_scripts = []
74
+ # dict of environment, as extracted from plan
75
+ self.plan_env = {}
76
+ # dict indexed by test name, value is dict with fmf-parsed metadata
77
+ self.tests = {}
78
+ # dict indexed by test name, value is pathlib.Path of relative path
79
+ # of the fmf metadata root towards the test metadata location
80
+ self.test_dirs = {}
81
+
82
+ # fmf.Context instance, as used for test discovery
83
+ context = fmf.Context(**context) if context else fmf.Context()
84
+ # allow the user to pass fmf.Tree directly, greatly speeding up the
85
+ # instantiation of multiple FMFTests instances
86
+ tree = fmf_tree.copy() if isinstance(fmf_tree, fmf.Tree) else fmf.Tree(fmf_tree)
87
+ tree.adjust(context=context)
88
+
89
+ # Path of the metadata root
90
+ self.root = Path(tree.root)
91
+
92
+ # lookup the plan first
93
+ if plan_name:
94
+ plan = tree.find(plan_name)
95
+ if not plan:
96
+ raise ValueError(f"plan {plan_name} not found in {tree.root}")
97
+ if "test" in plan.data:
98
+ raise ValueError(f"plan {plan_name} appears to be a test")
99
+ # fall back to a dummy plan
100
+ else:
101
+ class plan: # noqa: N801
102
+ data = {}
103
+
104
+ # gather and merge plan-defined environment variables
105
+ #
106
+ # environment:
107
+ # - FOO: BAR
108
+ # BAR: BAZ
109
+ for entry in listlike(plan.data, "environment"):
110
+ self.plan_env.update(entry)
111
+
112
+ # gather all prepare scripts / packages
113
+ #
114
+ # prepare:
115
+ # - how: install
116
+ # package:
117
+ # - some-rpm-name
118
+ # - how: shell
119
+ # script:
120
+ # - some-command
121
+ for entry in listlike(plan.data, "prepare"):
122
+ if entry.get("how") == "install":
123
+ self.prepare_pkgs += listlike(entry, "package")
124
+ elif entry.get("how") == "shell":
125
+ self.prepare_scripts += listlike(entry, "script")
126
+
127
+ # gather all finish scripts, same as prepare scripts
128
+ for entry in listlike(plan.data, "finish"):
129
+ if entry.get("how") == "shell":
130
+ self.finish_scripts += listlike(entry, "script")
131
+
132
+ # gather all tests selected by the plan
133
+ #
134
+ # discover:
135
+ # - how: fmf
136
+ # filter:
137
+ # - tag:some_tag
138
+ # test:
139
+ # - some-test-regex
140
+ # exclude:
141
+ # - some-test-regex
142
+ plan_filters = collections.defaultdict(list)
143
+ for entry in listlike(plan.data, "discover"):
144
+ if entry.get("how") != "fmf":
145
+ continue
146
+ for meta_name in ("filter", "test", "exclude"):
147
+ if value := listlike(entry, meta_name):
148
+ plan_filters[meta_name] += value
149
+
150
+ prune_kwargs = {}
151
+ if names:
152
+ prune_kwargs["names"] = names
153
+ elif "test" in plan_filters:
154
+ prune_kwargs["names"] = plan_filters["test"]
155
+ if filters:
156
+ prune_kwargs["filters"] = filters
157
+ elif "filter" in plan_filters:
158
+ prune_kwargs["filters"] = plan_filters["filter"]
159
+ if conditions:
160
+ prune_kwargs["conditions"] = conditions
161
+ if not excludes:
162
+ excludes = plan_filters.get("exclude")
163
+
164
+ # actually discover the tests
165
+ for child in tree.prune(**prune_kwargs):
166
+ # excludes not supported by .prune(), we have to do it here
167
+ if excludes and any(re.match(x, child.name) for x in excludes):
168
+ continue
169
+ # only tests
170
+ if "test" not in child.data:
171
+ continue
172
+ # only enabled tests
173
+ if "enabled" in child.data and not child.data["enabled"]:
174
+ continue
175
+ # no manual tests and no stories
176
+ if child.data.get("manual") or child.data.get("story"):
177
+ continue
178
+ # after adjusting above, any adjusts are useless, free some space
179
+ if "adjust" in child.data:
180
+ del child.data["adjust"]
181
+
182
+ self.tests[child.name] = child.data
183
+ # child.sources ie. ['/abs/path/to/some.fmf', '/abs/path/to/some/node.fmf']
184
+ self.test_dirs[child.name] = \
185
+ Path(child.sources[-1]).parent.relative_to(self.root)
186
+
187
+
188
+ def test_pkg_requires(data, key="require"):
189
+ """
190
+ Yield RPM package names specified by test 'data' (fmf metadata dict)
191
+ in the metadata 'key' (require or recommend), ignoring any non-RPM-package
192
+ requires/recommends.
193
+ """
194
+ for entry in listlike(data, key):
195
+ # skip type:library and type:path
196
+ if not isinstance(entry, str):
197
+ continue
198
+ # skip "fake RPMs" that begin with 'library('
199
+ if entry.startswith("library("):
200
+ continue
201
+ yield entry
202
+
203
+
204
+ def all_pkg_requires(fmf_tests, key="require"):
205
+ """
206
+ Yield RPM package names from the plan and all tests discovered by
207
+ a class FMFTests instance 'fmf_tests', ignoring any non-RPM-package
208
+ requires/recommends.
209
+ """
210
+ # use a set to avoid duplicates
211
+ pkgs = set()
212
+ pkgs.update(fmf_tests.prepare_pkgs)
213
+ for data in fmf_tests.tests.values():
214
+ pkgs.update(test_pkg_requires(data, key))
215
+ 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'}