atex 0.8__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.
atex/executor/executor.py CHANGED
@@ -1,4 +1,6 @@
1
1
  import os
2
+ import enum
3
+ import time
2
4
  import select
3
5
  import threading
4
6
  import contextlib
@@ -27,10 +29,12 @@ class Executor:
27
29
  tests_data = atex.fmf.FMFTests(tests_repo, "/plans/default")
28
30
 
29
31
  with Executor(tests_data, conn) as e:
30
- e.upload_tests(tests_repo)
31
- e.setup_plan()
32
- e.run_test("/some/test", "results/here.json", "uploaded/files/here")
32
+ e.upload_tests()
33
+ e.plan_prepare()
34
+ Path("output_here").mkdir()
35
+ e.run_test("/some/test", "output_here")
33
36
  e.run_test(...)
37
+ e.plan_finish()
34
38
 
35
39
  One Executor instance may be used to run multiple tests sequentially.
36
40
  In addition, multiple Executor instances can run in parallel on the same
@@ -39,27 +43,31 @@ class Executor:
39
43
  conn.cmd(["mkdir", "-p", "/shared"])
40
44
 
41
45
  with Executor(tests_data, conn, state_dir="/shared") as e:
42
- e.upload_tests(tests_repo)
43
- e.setup_plan()
46
+ e.upload_tests()
47
+ e.plan_prepare()
44
48
 
45
49
  # in parallel (ie. threading or multiprocessing)
46
50
  with Executor(tests_data, unique_conn, state_dir="/shared") as e:
47
51
  e.run_test(...)
48
52
  """
49
53
 
50
- def __init__(self, fmf_tests, connection, *, state_dir=None):
54
+ def __init__(self, fmf_tests, connection, *, env=None, state_dir=None):
51
55
  """
52
56
  'fmf_tests' is a class FMFTests instance with (discovered) tests.
53
57
 
54
58
  'connection' is a class Connection instance, already fully connected.
55
59
 
60
+ 'env' is a dict of extra environment variables to pass to the
61
+ plan prepare/finish scripts and to all tests.
62
+
56
63
  'state_dir' is a string or Path specifying path on the remote system for
57
64
  storing additional data, such as tests, execution wrappers, temporary
58
65
  plan-exported variables, etc. If left as None, a tmpdir is used.
59
66
  """
60
67
  self.lock = threading.RLock()
61
- self.conn = connection
62
68
  self.fmf_tests = fmf_tests
69
+ self.conn = connection
70
+ self.env = env or {}
63
71
  self.state_dir = state_dir
64
72
  self.work_dir = None
65
73
  self.tests_dir = None
@@ -96,6 +104,9 @@ class Executor:
96
104
  # use the tmpdir as work_dir, avoid extra mkdir over conn
97
105
  self.work_dir = tmp_dir
98
106
 
107
+ # create / truncate the TMT_PLAN_ENVIRONMENT_FILE
108
+ self.conn.cmd(("truncate", "-s", "0", self.plan_env_file), check=True)
109
+
99
110
  def cleanup(self):
100
111
  with self.lock:
101
112
  work_dir = self.work_dir
@@ -109,8 +120,12 @@ class Executor:
109
120
  self.plan_env_file = None
110
121
 
111
122
  def __enter__(self):
112
- self.setup()
113
- return self
123
+ try:
124
+ self.setup()
125
+ return self
126
+ except Exception:
127
+ self.cleanup()
128
+ raise
114
129
 
115
130
  def __exit__(self, exc_type, exc_value, traceback):
116
131
  self.cleanup()
@@ -131,13 +146,32 @@ class Executor:
131
146
  f"remote:{self.tests_dir}",
132
147
  )
133
148
 
134
- def setup_plan(self):
149
+ def _run_prepare_scripts(self, scripts):
150
+ # make envionment for 'prepare' scripts
151
+ env = {
152
+ **self.fmf_tests.plan_env,
153
+ **self.env,
154
+ "TMT_PLAN_ENVIRONMENT_FILE": self.plan_env_file,
155
+ }
156
+ env_args = (f"{k}={v}" for k, v in env.items())
157
+ # run the scripts
158
+ for script in scripts:
159
+ self.conn.cmd(
160
+ ("env", *env_args, "bash"),
161
+ input=script,
162
+ text=True,
163
+ check=True,
164
+ stdout=None if util.in_debug_mode() else subprocess.DEVNULL,
165
+ stderr=subprocess.STDOUT,
166
+ )
167
+
168
+ def plan_prepare(self):
135
169
  """
136
170
  Install packages and run scripts extracted from a TMT plan by a FMFTests
137
171
  instance given during class initialization.
138
172
 
139
- Also prepare additional environment for tests, ie. create and export
140
- a path to TMT_PLAN_ENVIRONMENT_FILE.
173
+ Also run additional scripts specified under the 'prepare' step inside
174
+ the fmf metadata of a plan.
141
175
  """
142
176
  # install packages from the plan
143
177
  if self.fmf_tests.prepare_pkgs:
@@ -151,65 +185,74 @@ class Executor:
151
185
  stderr=subprocess.STDOUT,
152
186
  )
153
187
 
154
- # make envionment for 'prepare' scripts
155
- self.conn.cmd(("truncate", "-s", "0", self.plan_env_file), check=True)
156
- env = self.fmf_tests.plan_env.copy()
157
- env["TMT_PLAN_ENVIRONMENT_FILE"] = self.plan_env_file
158
- env_args = (f"{k}={v}" for k, v in env.items())
188
+ # run 'prepare' scripts from the plan
189
+ if scripts := self.fmf_tests.prepare_scripts:
190
+ self._run_prepare_scripts(scripts)
159
191
 
160
- # run the prepare scripts
161
- for script in self.fmf_tests.prepare_scripts:
162
- self.conn.cmd(
163
- ("env", *env_args, "bash"),
164
- input=script,
165
- text=True,
166
- check=True,
167
- stdout=None if util.in_debug_mode() else subprocess.DEVNULL,
168
- stderr=subprocess.STDOUT,
169
- )
192
+ def plan_finish(self):
193
+ """
194
+ Run any scripts specified under the 'finish' step inside
195
+ the fmf metadata of a plan.
196
+ """
197
+ if scripts := self.fmf_tests.finish_scripts:
198
+ self._run_prepare_scripts(scripts)
170
199
 
171
- def run_test(self, test_name, json_file, files_dir, *, env=None):
200
+ class State(enum.Enum):
201
+ STARTING_TEST = enum.auto()
202
+ READING_CONTROL = enum.auto()
203
+ WAITING_FOR_EXIT = enum.auto()
204
+ RECONNECTING = enum.auto()
205
+
206
+ def run_test(self, test_name, output_dir, *, env=None):
172
207
  """
173
208
  Run one test on the remote system.
174
209
 
175
210
  'test_name' is a string with test name.
176
211
 
177
- 'json_file' is a destination file (string or Path) for results.
178
-
179
- 'files_dir' is a destination dir (string or Path) for uploaded files.
212
+ 'output_dir' is a destination dir (string or Path) for results reported
213
+ and files uploaded by the test. Results are always stored in a line-JSON
214
+ format in a file named 'results', files are always uploaded to directory
215
+ named 'files', both inside 'output_dir'.
216
+ The path for 'output_dir' must already exist and be an empty directory
217
+ (ie. typically a tmpdir).
180
218
 
181
219
  'env' is a dict of extra environment variables to pass to the test.
182
220
 
183
221
  Returns an integer exit code of the test script.
184
222
  """
223
+ output_dir = Path(output_dir)
185
224
  test_data = self.fmf_tests.tests[test_name]
186
225
 
187
- # start with fmf-plan-defined environment
188
- env_vars = self.fmf_tests.plan_env.copy()
189
- # append fmf-test-defined environment into it
190
- for item in fmf.listlike(test_data, "environment"):
191
- env_vars.update(item)
192
- # append additional variables typically exported by tmt
193
- env_vars["TMT_PLAN_ENVIRONMENT_FILE"] = self.plan_env_file
194
- env_vars["TMT_TEST_NAME"] = test_name
195
- env_vars["ATEX_TEST_NAME"] = test_name
196
- # append variables given to this function call
197
- if env:
198
- env_vars.update(env)
199
-
200
226
  # run a setup script, preparing wrapper + test scripts
201
227
  setup_script = scripts.test_setup(
202
228
  test=scripts.Test(test_name, test_data, self.fmf_tests.test_dirs[test_name]),
203
229
  tests_dir=self.tests_dir,
204
230
  wrapper_exec=f"{self.work_dir}/wrapper.sh",
205
231
  test_exec=f"{self.work_dir}/test.sh",
232
+ test_yaml=f"{self.work_dir}/metadata.yaml",
206
233
  )
207
234
  self.conn.cmd(("bash",), input=setup_script, text=True, check=True)
208
235
 
236
+ # start with fmf-plan-defined environment
237
+ env_vars = {
238
+ **self.fmf_tests.plan_env,
239
+ "TMT_PLAN_ENVIRONMENT_FILE": self.plan_env_file,
240
+ "TMT_TEST_NAME": test_name,
241
+ "TMT_TEST_METADATA": f"{self.work_dir}/metadata.yaml",
242
+ }
243
+ # append fmf-test-defined environment into it
244
+ for item in fmf.listlike(test_data, "environment"):
245
+ env_vars.update(item)
246
+ # append the Executor-wide environment passed to __init__()
247
+ env_vars.update(self.env)
248
+ # append variables given to this function call
249
+ if env:
250
+ env_vars.update(env)
251
+
209
252
  with contextlib.ExitStack() as stack:
210
- reporter = stack.enter_context(Reporter(json_file, files_dir))
211
- testout_fd = stack.enter_context(reporter.open_tmpfile())
253
+ reporter = stack.enter_context(Reporter(output_dir, "results", "files"))
212
254
  duration = Duration(test_data.get("duration", "5m"))
255
+ control = testcontrol.TestControl(reporter=reporter, duration=duration)
213
256
 
214
257
  test_proc = None
215
258
  control_fd = None
@@ -223,23 +266,19 @@ class Executor:
223
266
  test_proc.wait()
224
267
  raise TestAbortedError(msg) from None
225
268
 
269
+ exception = None
270
+
226
271
  try:
227
- # TODO: probably enum
228
- state = "starting_test"
272
+ state = self.State.STARTING_TEST
229
273
  while not duration.out_of_time():
230
274
  with self.lock:
231
275
  if self.cancelled:
232
276
  abort("cancel requested")
233
277
 
234
- if state == "starting_test":
278
+ if state == self.State.STARTING_TEST:
235
279
  control_fd, pipe_w = os.pipe()
236
280
  os.set_blocking(control_fd, False)
237
- control = testcontrol.TestControl(
238
- control_fd=control_fd,
239
- reporter=reporter,
240
- duration=duration,
241
- testout_fd=testout_fd,
242
- )
281
+ control.reassign(control_fd)
243
282
  # reconnect/reboot count (for compatibility)
244
283
  env_vars["TMT_REBOOT_COUNT"] = str(reconnects)
245
284
  env_vars["TMT_TEST_RESTART_COUNT"] = str(reconnects)
@@ -249,13 +288,13 @@ class Executor:
249
288
  test_proc = self.conn.cmd(
250
289
  ("env", *env_args, f"{self.work_dir}/wrapper.sh"),
251
290
  stdout=pipe_w,
252
- stderr=testout_fd,
291
+ stderr=reporter.testout_fobj.fileno(),
253
292
  func=util.subprocess_Popen,
254
293
  )
255
294
  os.close(pipe_w)
256
- state = "reading_control"
295
+ state = self.State.READING_CONTROL
257
296
 
258
- elif state == "reading_control":
297
+ elif state == self.State.READING_CONTROL:
259
298
  rlist, _, xlist = select.select((control_fd,), (), (control_fd,), 0.1)
260
299
  if xlist:
261
300
  abort(f"got exceptional condition on control_fd {control_fd}")
@@ -264,9 +303,9 @@ class Executor:
264
303
  if control.eof:
265
304
  os.close(control_fd)
266
305
  control_fd = None
267
- state = "waiting_for_exit"
306
+ state = self.State.WAITING_FOR_EXIT
268
307
 
269
- elif state == "waiting_for_exit":
308
+ elif state == self.State.WAITING_FOR_EXIT:
270
309
  # control stream is EOF and it has nothing for us to read,
271
310
  # we're now just waiting for proc to cleanly terminate
272
311
  try:
@@ -279,7 +318,7 @@ class Executor:
279
318
  self.conn.disconnect()
280
319
  # if reconnect was requested, do so, otherwise abort
281
320
  if control.reconnect:
282
- state = "reconnecting"
321
+ state = self.State.RECONNECTING
283
322
  if control.reconnect != "always":
284
323
  control.reconnect = None
285
324
  else:
@@ -291,13 +330,18 @@ class Executor:
291
330
  except subprocess.TimeoutExpired:
292
331
  pass
293
332
 
294
- elif state == "reconnecting":
333
+ elif state == self.State.RECONNECTING:
295
334
  try:
296
335
  self.conn.connect(block=False)
297
336
  reconnects += 1
298
- state = "starting_test"
337
+ state = self.State.STARTING_TEST
299
338
  except BlockingIOError:
300
339
  pass
340
+ except ConnectionError:
341
+ # can happen when ie. ssh is connecting over a LocalForward port,
342
+ # causing 'read: Connection reset by peer' instead of timeout
343
+ # - just retry again after a short delay
344
+ time.sleep(0.5)
301
345
 
302
346
  else:
303
347
  raise AssertionError("reached unexpected state")
@@ -305,12 +349,19 @@ class Executor:
305
349
  else:
306
350
  abort("test duration timeout reached")
307
351
 
308
- # testing successful, do post-testing tasks
352
+ # testing successful
309
353
 
310
354
  # test wrapper hasn't provided exitcode
311
355
  if control.exit_code is None:
312
356
  abort("exitcode not reported, wrapper bug?")
313
357
 
358
+ return control.exit_code
359
+
360
+ except Exception as e:
361
+ exception = e
362
+ raise
363
+
364
+ finally:
314
365
  # partial results that were never reported
315
366
  if control.partial_results:
316
367
  for result in control.partial_results.values():
@@ -320,59 +371,32 @@ class Executor:
320
371
  control.nameless_result_seen = True
321
372
  if testout := result.get("testout"):
322
373
  try:
323
- reporter.link_tmpfile_to(testout_fd, testout, name)
374
+ reporter.link_testout(testout, name)
324
375
  except FileExistsError:
325
376
  raise testcontrol.BadReportJSONError(
326
377
  f"file '{testout}' already exists",
327
378
  ) from None
328
379
  reporter.report(result)
329
380
 
330
- # test hasn't reported a result for itself, add an automatic one
331
- # as specified in RESULTS.md
332
- # {"status": "pass", "testout": "output.txt"}
333
- if not control.nameless_result_seen:
334
- reporter.link_tmpfile_to(testout_fd, "output.txt")
381
+ # if an unexpected infrastructure-related exception happened
382
+ if exception:
383
+ try:
384
+ reporter.link_testout("output.txt")
385
+ except FileExistsError:
386
+ pass
335
387
  reporter.report({
336
- "status": "pass" if control.exit_code == 0 else "fail",
388
+ "status": "infra",
389
+ "note": repr(exception),
337
390
  "testout": "output.txt",
338
391
  })
339
392
 
340
- return control.exit_code
341
-
342
- except Exception:
343
- # if the test hasn't reported a result for itself, but still
344
- # managed to break something, provide at least the default log
345
- # for manual investigation - otherwise test output disappears
346
- if not control.nameless_result_seen:
393
+ # if the test hasn't reported a result for itself
394
+ elif not control.nameless_result_seen:
347
395
  try:
348
- reporter.link_tmpfile_to(testout_fd, "output.txt")
349
- reporter.report({
350
- "status": "infra",
351
- "testout": "output.txt",
352
- })
353
- # in case outout.txt exists as a directory
396
+ reporter.link_testout("output.txt")
354
397
  except FileExistsError:
355
398
  pass
356
- raise
357
-
358
-
359
- #__all__ = [
360
- # info.name for info in _pkgutil.iter_modules(__spec__.submodule_search_locations)
361
- #]
362
- #
363
- #
364
- #import importlib as _importlib
365
- #import pkgutil as _pkgutil
366
- #
367
- #
368
- #def __dir__():
369
- # return __all__
370
- #
371
- #
372
- ## lazily import submodules
373
- #def __getattr__(attr):
374
- # # importing a module known to exist
375
- # if attr in __all__:
376
- # return _importlib.import_module(f".{attr}", __name__)
377
- # else:
378
- # raise AttributeError(f"module '{__name__}' has no attribute '{attr}'")
399
+ reporter.report({
400
+ "status": "pass" if control.exit_code == 0 else "fail",
401
+ "testout": "output.txt",
402
+ })
atex/executor/reporter.py CHANGED
@@ -1,70 +1,72 @@
1
1
  import os
2
2
  import json
3
- import ctypes
4
- import ctypes.util
5
- import contextlib
6
3
  from pathlib import Path
7
4
 
8
5
  from .. import util
9
6
 
10
7
 
11
- libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
12
-
13
- # int linkat(int olddirfd, const char *oldpath, int newdirfd, const char *newpath, int flags)
14
- libc.linkat.argtypes = (
15
- ctypes.c_int,
16
- ctypes.c_char_p,
17
- ctypes.c_int,
18
- ctypes.c_char_p,
19
- ctypes.c_int,
20
- )
21
- libc.linkat.restype = ctypes.c_int
22
-
23
- # fcntl.h:#define AT_EMPTY_PATH 0x1000 /* Allow empty relative pathname */
24
- AT_EMPTY_PATH = 0x1000
25
-
26
- # fcntl.h:#define AT_FDCWD -100 /* Special value used to indicate
27
- AT_FDCWD = -100
28
-
29
-
30
- def linkat(*args):
31
- if (ret := libc.linkat(*args)) == -1:
32
- errno = ctypes.get_errno()
33
- raise OSError(errno, os.strerror(errno))
34
- return ret
35
-
36
-
37
8
  class Reporter:
38
9
  """
39
10
  Collects reported results (in a format specified by RESULTS.md) for
40
11
  a specific test, storing them persistently.
41
12
  """
42
13
 
43
- def __init__(self, json_file, files_dir):
44
- """
45
- 'json_file' is a destination file (string or Path) for results.
14
+ # internal name, stored inside 'output_dir' and hardlinked to
15
+ # 'testout'-JSON-key-specified result entries; deleted on exit
16
+ TESTOUT = "testout.temp"
46
17
 
47
- 'files_dir' is a destination dir (string or Path) for uploaded files.
18
+ def __init__(self, output_dir, results_file, files_dir):
48
19
  """
49
- self.json_file = json_file
50
- self.files_dir = Path(files_dir)
51
- self.json_fobj = None
20
+ 'output_dir' is a destination dir (string or Path) for results reported
21
+ and files uploaded.
52
22
 
53
- def __enter__(self):
54
- if self.json_file.exists():
55
- raise FileExistsError(f"{self.json_file} already exists")
56
- self.json_fobj = open(self.json_file, "w")
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")
57
45
 
58
46
  if self.files_dir.exists():
59
47
  raise FileExistsError(f"{self.files_dir} already exists")
60
48
  self.files_dir.mkdir()
61
49
 
62
- return self
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
63
67
 
64
68
  def __exit__(self, exc_type, exc_value, traceback):
65
- if self.json_fobj:
66
- self.json_fobj.close()
67
- self.json_fobj = None
69
+ self.stop()
68
70
 
69
71
  def report(self, result_line):
70
72
  """
@@ -72,35 +74,28 @@ class Reporter:
72
74
 
73
75
  'result_line' is a dict in the format specified by RESULTS.md.
74
76
  """
75
- json.dump(result_line, self.json_fobj, indent=None)
76
- self.json_fobj.write("\n")
77
- self.json_fobj.flush()
77
+ json.dump(result_line, self.results_fobj, indent=None)
78
+ self.results_fobj.write("\n")
79
+ self.results_fobj.flush()
78
80
 
79
- @contextlib.contextmanager
80
- def open_tmpfile(self, open_mode=os.O_WRONLY):
81
- """
82
- Open an anonymous (name-less) file for writing and yield its file
83
- descriptor (int) as context, closing it when the context is exited.
84
- """
85
- flags = open_mode | os.O_TMPFILE
86
- fd = os.open(self.files_dir, flags, 0o644)
87
- try:
88
- yield fd
89
- finally:
90
- os.close(fd)
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
91
87
 
92
- def link_tmpfile_to(self, fd, file_name, result_name=None):
88
+ def open_file(self, file_name, result_name=None, mode="wb"):
93
89
  """
94
- Store a file named 'file_name' in a directory relevant to 'result_name'
95
- whose 'fd' (a file descriptor) was created by .open_tmpfile().
96
-
97
- This function can be called multiple times with the same 'fd', and
98
- does not close or otherwise alter the descriptor.
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().
99
93
 
100
- If 'result_name' is not given, link files to the test (name) itself.
94
+ If 'result_name' (typically a subtest) is not given, open the file
95
+ for the test (name) itself.
101
96
  """
102
- result_name = util.normalize_path(result_name) if result_name else "."
103
- # /path/to/files_dir / path/to/subresult / path/to/file.log
104
- file_path = self.files_dir / result_name / util.normalize_path(file_name)
105
- file_path.parent.mkdir(parents=True, exist_ok=True)
106
- linkat(fd, b"", AT_FDCWD, bytes(file_path), AT_EMPTY_PATH)
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))
atex/executor/scripts.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import collections
2
+ import yaml
2
3
  from pathlib import Path
3
4
 
4
5
  from .. import util, fmf
@@ -102,7 +103,7 @@ def _install_packages(pkgs, extra_opts=None):
102
103
  """) # noqa: E501
103
104
 
104
105
 
105
- def test_setup(*, test, wrapper_exec, test_exec, **kwargs):
106
+ def test_setup(*, test, wrapper_exec, test_exec, test_yaml, **kwargs):
106
107
  """
107
108
  Generate a bash script that should prepare the remote end for test
108
109
  execution.
@@ -111,12 +112,12 @@ def test_setup(*, test, wrapper_exec, test_exec, **kwargs):
111
112
  scripts: a test script (contents of 'test' from FMF) and a wrapper script
112
113
  to run the test script.
113
114
 
115
+ 'test' is a class Test instance.
116
+
114
117
  'wrapper_exec' is the remote path where the wrapper script should be put.
115
118
 
116
119
  'test_exec' is the remote path where the test script should be put.
117
120
 
118
- 'test' is a class Test instance.
119
-
120
121
  Any 'kwargs' are passed to test_wrapper().
121
122
  """
122
123
  out = "#!/bin/bash\n"
@@ -134,6 +135,11 @@ def test_setup(*, test, wrapper_exec, test_exec, **kwargs):
134
135
  if recommend := list(fmf.test_pkg_requires(test.data, "recommend")):
135
136
  out += _install_packages(recommend, ("--skip-broken",)) + "\n"
136
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"
142
+
137
143
  # make the wrapper script
138
144
  out += f"cat > '{wrapper_exec}' <<'ATEX_SETUP_EOF'\n"
139
145
  out += test_wrapper(