proj-flow 0.20.3__py3-none-any.whl → 0.22.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.
- proj_flow/__init__.py +1 -1
- proj_flow/api/arg.py +2 -0
- proj_flow/api/completers.py +1 -1
- proj_flow/api/env.py +45 -15
- proj_flow/api/release.py +1 -1
- proj_flow/api/step.py +20 -5
- proj_flow/{ext/cplusplus/cmake/presets.py → base/cmake_presets.py} +40 -16
- proj_flow/base/plugins.py +12 -7
- proj_flow/cli/finder.py +4 -3
- proj_flow/dependency.py +6 -2
- proj_flow/ext/cplusplus/cmake/__init__.py +2 -2
- proj_flow/ext/cplusplus/cmake/parser.py +4 -3
- proj_flow/ext/cplusplus/cmake/steps.py +9 -9
- proj_flow/ext/cplusplus/conan/__init__.py +8 -4
- proj_flow/ext/github/cli.py +1 -3
- proj_flow/ext/github/publishing.py +1 -0
- proj_flow/ext/python/rtdocs.py +37 -8
- proj_flow/ext/python/steps.py +5 -4
- proj_flow/ext/python/version.py +12 -12
- proj_flow/ext/sign/__init__.py +2 -2
- proj_flow/ext/test_runner/__init__.py +6 -0
- proj_flow/ext/test_runner/cli.py +416 -0
- proj_flow/ext/test_runner/driver/__init__.py +2 -0
- proj_flow/ext/test_runner/driver/commands.py +74 -0
- proj_flow/ext/test_runner/driver/test.py +610 -0
- proj_flow/ext/test_runner/driver/testbed.py +141 -0
- proj_flow/ext/test_runner/utils/__init__.py +2 -0
- proj_flow/ext/test_runner/utils/archives.py +56 -0
- proj_flow/log/rich_text/api.py +3 -4
- proj_flow/minimal/list.py +126 -10
- proj_flow/minimal/run.py +3 -2
- proj_flow/project/api.py +1 -0
- {proj_flow-0.20.3.dist-info → proj_flow-0.22.0.dist-info}/METADATA +17 -7
- {proj_flow-0.20.3.dist-info → proj_flow-0.22.0.dist-info}/RECORD +37 -29
- {proj_flow-0.20.3.dist-info → proj_flow-0.22.0.dist-info}/WHEEL +0 -0
- {proj_flow-0.20.3.dist-info → proj_flow-0.22.0.dist-info}/entry_points.txt +0 -0
- {proj_flow-0.20.3.dist-info → proj_flow-0.22.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
# Copyright (c) 2026 Marcin Zdun
|
|
2
|
+
# This code is licensed under MIT license (see LICENSE for details)
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import random
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import shutil
|
|
10
|
+
import string
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from difflib import unified_diff
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Callable, TypeVar, cast
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from yaml import CDumper as Dumper
|
|
22
|
+
from yaml import CLoader as Loader
|
|
23
|
+
except ImportError:
|
|
24
|
+
from yaml import Dumper, Loader
|
|
25
|
+
|
|
26
|
+
_flds = ["Return code", "Standard out", "Standard err"]
|
|
27
|
+
_streams = ["stdout", "stderr"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _last_enter(value: str):
|
|
31
|
+
if len(value) and value[-1] == "\n":
|
|
32
|
+
value = value[:-1] + "\\n"
|
|
33
|
+
return value + "\n"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _diff(expected, actual):
|
|
37
|
+
expected = _last_enter(expected).splitlines(keepends=False)
|
|
38
|
+
actual = _last_enter(actual).splitlines(keepends=False)
|
|
39
|
+
return "\n".join(list(unified_diff(expected, actual, lineterm=""))[2:])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _alt_sep(input: str, value: str, var: str):
|
|
43
|
+
split = input.split(value)
|
|
44
|
+
first = split[0]
|
|
45
|
+
split = split[1:]
|
|
46
|
+
for index in range(len(split)):
|
|
47
|
+
m = re.match(r"^(\S+)((\n|.)*)$", split[index])
|
|
48
|
+
if m is None:
|
|
49
|
+
continue
|
|
50
|
+
g2 = m.group(2)
|
|
51
|
+
if g2 is None:
|
|
52
|
+
g2 = ""
|
|
53
|
+
split[index] = "{}{}".format(m.group(1).replace(os.sep, "/"), g2)
|
|
54
|
+
return var.join([first, *split])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def to_lines(stream: str, is_json: bool):
|
|
58
|
+
lines = stream.split("\n")
|
|
59
|
+
if is_json and len(lines) > 1 and lines[-1] == "":
|
|
60
|
+
lines = lines[:-1]
|
|
61
|
+
lines[-1] += "\n"
|
|
62
|
+
if len(lines) == 1:
|
|
63
|
+
return lines[0]
|
|
64
|
+
return lines
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class Env:
|
|
69
|
+
target: str
|
|
70
|
+
target_name: str
|
|
71
|
+
build_dir: str
|
|
72
|
+
data_dir: str
|
|
73
|
+
inst_dir: str
|
|
74
|
+
tempdir: str
|
|
75
|
+
version: str
|
|
76
|
+
counter_digits: int
|
|
77
|
+
counter_total: int
|
|
78
|
+
handlers: dict[str, tuple[int, Callable[["Test", list[str]], None]]]
|
|
79
|
+
data_dir_alt: str | None = None
|
|
80
|
+
tempdir_alt: str | None = None
|
|
81
|
+
# TODO: installable patches
|
|
82
|
+
builtin_patches: dict[str, str] | None = None
|
|
83
|
+
reportable_env_prefix: str | None = None
|
|
84
|
+
|
|
85
|
+
def expand(
|
|
86
|
+
self, input: str, tempdir: str, cwd: str, additional: dict[str, str] = {}
|
|
87
|
+
):
|
|
88
|
+
input = (
|
|
89
|
+
input.replace("$TMP", tempdir)
|
|
90
|
+
.replace("$CWD", cwd)
|
|
91
|
+
.replace("$DATA", self.data_dir)
|
|
92
|
+
.replace("$INST", self.inst_dir)
|
|
93
|
+
.replace("$VERSION", self.version)
|
|
94
|
+
)
|
|
95
|
+
for key, value in additional.items():
|
|
96
|
+
input = input.replace(f"${key}", value)
|
|
97
|
+
return input
|
|
98
|
+
|
|
99
|
+
def fix(self, raw_input: bytes, cwd: str, patches: dict[str, str]):
|
|
100
|
+
if os.name == "nt":
|
|
101
|
+
raw_input = raw_input.replace(b"\r\n", b"\n")
|
|
102
|
+
input = raw_input.decode("UTF-8")
|
|
103
|
+
input = _alt_sep(input, cwd, "$CWD")
|
|
104
|
+
input = _alt_sep(input, self.tempdir, "$TMP")
|
|
105
|
+
input = _alt_sep(input, self.data_dir, "$DATA")
|
|
106
|
+
input = input.replace(self.version, "$VERSION")
|
|
107
|
+
|
|
108
|
+
if self.tempdir_alt is not None:
|
|
109
|
+
input = _alt_sep(input, self.tempdir_alt, "$TMP")
|
|
110
|
+
if self.data_dir_alt:
|
|
111
|
+
input = _alt_sep(input, self.data_dir_alt, "$DATA")
|
|
112
|
+
|
|
113
|
+
builtins = self.builtin_patches or {}
|
|
114
|
+
|
|
115
|
+
lines = input.split("\n")
|
|
116
|
+
for patch, replacement in builtins.items():
|
|
117
|
+
pattern = re.compile(patch)
|
|
118
|
+
for lineno in range(len(lines)):
|
|
119
|
+
m = pattern.match(lines[lineno])
|
|
120
|
+
if m:
|
|
121
|
+
lines[lineno] = m.expand(replacement)
|
|
122
|
+
|
|
123
|
+
for patch, replacement in patches.items():
|
|
124
|
+
pattern = re.compile(patch)
|
|
125
|
+
for lineno in range(len(lines)):
|
|
126
|
+
m = pattern.match(lines[lineno])
|
|
127
|
+
if m:
|
|
128
|
+
lines[lineno] = m.expand(replacement)
|
|
129
|
+
return "\n".join(lines)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _test_name(filename: Path) -> str:
|
|
133
|
+
dirname = filename.parent.name
|
|
134
|
+
basename = filename.stem
|
|
135
|
+
|
|
136
|
+
def num(s: str):
|
|
137
|
+
items = s.split("-")
|
|
138
|
+
if len(items) < 2:
|
|
139
|
+
return s
|
|
140
|
+
items[0] = f"({items[0]})"
|
|
141
|
+
return " ".join(items)
|
|
142
|
+
|
|
143
|
+
return f"{num(dirname)} :: {num(basename)}"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _paths(key: str, dirs: list[str]):
|
|
147
|
+
vals = [val for val in os.environ.get(key, "").split(os.pathsep) if val != ""]
|
|
148
|
+
named = f"${key}"
|
|
149
|
+
if named in dirs:
|
|
150
|
+
pos = dirs.index(named)
|
|
151
|
+
copy = [*dirs]
|
|
152
|
+
copy[pos:pos] = vals
|
|
153
|
+
vals = copy
|
|
154
|
+
else:
|
|
155
|
+
vals.extend(dirs)
|
|
156
|
+
return os.pathsep.join(vals)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
StrOrBytes = TypeVar("StrOrBytes", str, bytes)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class FileContents[StrOrBytes]:
|
|
164
|
+
filename: str
|
|
165
|
+
content: StrOrBytes | None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def load_file_contents(filename: str, environment: Env, cwd: str):
|
|
169
|
+
path = environment.expand(filename, environment.tempdir, cwd=cwd)
|
|
170
|
+
try:
|
|
171
|
+
content = Path(path).read_bytes()
|
|
172
|
+
except FileNotFoundError:
|
|
173
|
+
content = None
|
|
174
|
+
return FileContents(filename=filename, content=content)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def fix_file_contents(
|
|
178
|
+
self: FileContents[bytes], env: Env, cwd: str, patches: dict[str, str]
|
|
179
|
+
):
|
|
180
|
+
return FileContents(
|
|
181
|
+
filename=self.filename,
|
|
182
|
+
content=env.fix(self.content, cwd, patches) if self.content else None,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass
|
|
187
|
+
class FileWrite[StrOrBytes]:
|
|
188
|
+
generated: FileContents[StrOrBytes]
|
|
189
|
+
template: FileContents[StrOrBytes]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def fix_file_write(self: FileWrite[bytes], env: Env, cwd: str, patches: dict[str, str]):
|
|
193
|
+
return FileWrite(
|
|
194
|
+
generated=fix_file_contents(self.generated, env, cwd, patches),
|
|
195
|
+
template=fix_file_contents(self.template, env, cwd, patches),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class Test:
|
|
200
|
+
cwd: str
|
|
201
|
+
data: dict
|
|
202
|
+
filename: Path
|
|
203
|
+
name: str
|
|
204
|
+
ok: bool
|
|
205
|
+
post_args: list[list[str]]
|
|
206
|
+
current_env: Env | None
|
|
207
|
+
additional_env: dict[str, str]
|
|
208
|
+
|
|
209
|
+
linear: bool
|
|
210
|
+
disabled: bool | str
|
|
211
|
+
lang: str
|
|
212
|
+
args: list[str]
|
|
213
|
+
post: list[list[str]]
|
|
214
|
+
expected: tuple[int, str, str] | None
|
|
215
|
+
check: list[str]
|
|
216
|
+
writes: dict[str, str]
|
|
217
|
+
patches: dict[str, str]
|
|
218
|
+
env: dict[str, str | None]
|
|
219
|
+
prepare: list[list[str]]
|
|
220
|
+
cleanup: list[list[str]]
|
|
221
|
+
|
|
222
|
+
def __init__(self, data: dict, filename: Path, count: int):
|
|
223
|
+
self.cwd = os.getcwd()
|
|
224
|
+
self.data = data
|
|
225
|
+
self.filename = filename
|
|
226
|
+
self.name = _test_name(filename)
|
|
227
|
+
self.ok = True
|
|
228
|
+
self.post_args = []
|
|
229
|
+
self.current_env = None
|
|
230
|
+
self.additional_env = {}
|
|
231
|
+
|
|
232
|
+
self.linear = cast(bool, data.get("linear", False))
|
|
233
|
+
self.disabled = cast(bool | str, data.get("disabled", False))
|
|
234
|
+
self.lang = cast(str, data.get("lang", "en"))
|
|
235
|
+
self.args = []
|
|
236
|
+
self.post = []
|
|
237
|
+
self.expected = None
|
|
238
|
+
self.check = ["all"] * len(_streams)
|
|
239
|
+
self.writes = cast(dict[str, str], data.get("writes", {}))
|
|
240
|
+
self.patches = cast(dict[str, str], data.get("patches", {}))
|
|
241
|
+
self.env = {}
|
|
242
|
+
self.prepare = []
|
|
243
|
+
self.cleanup = []
|
|
244
|
+
|
|
245
|
+
if isinstance(self.disabled, bool):
|
|
246
|
+
self.ok = not self.disabled
|
|
247
|
+
elif isinstance(self.disabled, str):
|
|
248
|
+
self.ok = self.disabled != sys.platform
|
|
249
|
+
|
|
250
|
+
rebuild = False
|
|
251
|
+
|
|
252
|
+
_args = cast(str | list[str] | None, data.get("args"))
|
|
253
|
+
if _args is None:
|
|
254
|
+
self.ok = False
|
|
255
|
+
return
|
|
256
|
+
elif isinstance(_args, list):
|
|
257
|
+
rebuild = True
|
|
258
|
+
data["args"] = shlex.join(_args)
|
|
259
|
+
self.args = _args
|
|
260
|
+
else:
|
|
261
|
+
self.args = shlex.split(_args)
|
|
262
|
+
|
|
263
|
+
self.post_args, rebuild = self.__join_args("post", data, rebuild)
|
|
264
|
+
|
|
265
|
+
if "expected" not in data:
|
|
266
|
+
self.ok = False
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
_expected = cast(None | list, data.get("expected"))
|
|
270
|
+
if isinstance(_expected, list):
|
|
271
|
+
if len(_expected) != 3:
|
|
272
|
+
self.ok = False
|
|
273
|
+
return
|
|
274
|
+
_return, _stdout, _stderr = _expected
|
|
275
|
+
if isinstance(_stdout, list):
|
|
276
|
+
_stdout = "\n".join(_stdout)
|
|
277
|
+
if isinstance(_stderr, list):
|
|
278
|
+
_stderr = "\n".join(_stderr)
|
|
279
|
+
if (
|
|
280
|
+
not isinstance(_return, int)
|
|
281
|
+
or not isinstance(_stdout, str)
|
|
282
|
+
or not isinstance(_stderr, str)
|
|
283
|
+
):
|
|
284
|
+
self.ok = False
|
|
285
|
+
return
|
|
286
|
+
self.expected = _return, _stdout, _stderr
|
|
287
|
+
|
|
288
|
+
_check = cast(dict[str, str], data.get("check", {}))
|
|
289
|
+
for index in range(len(_streams)):
|
|
290
|
+
self.check[index] = _check.get(_streams[index], self.check[index])
|
|
291
|
+
|
|
292
|
+
_env = cast(dict[str, str | list[str] | None], data.get("env", {}))
|
|
293
|
+
self.env = {
|
|
294
|
+
key: _paths(key, value) if isinstance(value, list) else value
|
|
295
|
+
for key, value in _env.items()
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
self.prepare, rebuild = self.__join_args("prepare", data, rebuild)
|
|
299
|
+
self.cleanup, rebuild = self.__join_args("cleanup", data, rebuild)
|
|
300
|
+
|
|
301
|
+
if rebuild:
|
|
302
|
+
if self.expected:
|
|
303
|
+
is_json = self.filename.suffix == ".json"
|
|
304
|
+
data["expected"] = [
|
|
305
|
+
self.expected[0],
|
|
306
|
+
*[to_lines(stream, is_json) for stream in self.expected[1:]],
|
|
307
|
+
]
|
|
308
|
+
self.store()
|
|
309
|
+
|
|
310
|
+
def __join_args(self, key: str, data: dict, rebuild: bool):
|
|
311
|
+
_rebuild = rebuild
|
|
312
|
+
result: list[list[str]] = []
|
|
313
|
+
|
|
314
|
+
lines = cast(str | list[str | list[str]], data.get(key, []))
|
|
315
|
+
if isinstance(lines, str):
|
|
316
|
+
lines = [lines]
|
|
317
|
+
|
|
318
|
+
for index in range(len(lines)):
|
|
319
|
+
cmd = lines[index]
|
|
320
|
+
if isinstance(cmd, str):
|
|
321
|
+
result.append(shlex.split(cmd))
|
|
322
|
+
continue
|
|
323
|
+
|
|
324
|
+
_rebuild = True
|
|
325
|
+
data[key][index] = shlex.join(cmd)
|
|
326
|
+
result.append(cmd)
|
|
327
|
+
|
|
328
|
+
return result, _rebuild
|
|
329
|
+
|
|
330
|
+
def run_cmds(self, env: Env, ops: list[list[str]], tempdir: str) -> bool | None:
|
|
331
|
+
saved = self.current_env
|
|
332
|
+
self.current_env = env
|
|
333
|
+
try:
|
|
334
|
+
for op in ops:
|
|
335
|
+
orig = [*op]
|
|
336
|
+
is_safe = False
|
|
337
|
+
try:
|
|
338
|
+
name = op[0]
|
|
339
|
+
if name[:5] == "safe-":
|
|
340
|
+
name = name[5:]
|
|
341
|
+
is_safe = True
|
|
342
|
+
min_args, cb = env.handlers[name]
|
|
343
|
+
op = op[1:]
|
|
344
|
+
if len(op) < min_args:
|
|
345
|
+
return None
|
|
346
|
+
cb(self, [env.expand(o, tempdir, cwd=self.cwd) for o in op])
|
|
347
|
+
except Exception as ex:
|
|
348
|
+
if op[0] != "safe-rm":
|
|
349
|
+
print("Problem while handling", shlex.join(orig))
|
|
350
|
+
print(ex)
|
|
351
|
+
raise
|
|
352
|
+
if is_safe:
|
|
353
|
+
continue
|
|
354
|
+
return None
|
|
355
|
+
finally:
|
|
356
|
+
self.current_env = saved
|
|
357
|
+
return True
|
|
358
|
+
|
|
359
|
+
def run(self, environment: Env) -> tuple[int, str, str, list[FileWrite]] | None:
|
|
360
|
+
root = os.path.join(
|
|
361
|
+
"build",
|
|
362
|
+
".testing",
|
|
363
|
+
"".join(random.choice(string.ascii_letters) for _ in range(16)),
|
|
364
|
+
)
|
|
365
|
+
root = self.cwd = os.path.join(self.cwd, root)
|
|
366
|
+
os.makedirs(root, exist_ok=True)
|
|
367
|
+
|
|
368
|
+
prep = self.run_cmds(environment, self.prepare, environment.tempdir)
|
|
369
|
+
if prep is None:
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
expanded = [
|
|
373
|
+
environment.expand(arg, environment.tempdir, self.cwd, self.additional_env)
|
|
374
|
+
for arg in self.args
|
|
375
|
+
]
|
|
376
|
+
post_expanded = [
|
|
377
|
+
[
|
|
378
|
+
environment.expand(
|
|
379
|
+
arg, environment.tempdir, self.cwd, self.additional_env
|
|
380
|
+
)
|
|
381
|
+
for arg in cmd
|
|
382
|
+
]
|
|
383
|
+
for cmd in self.post_args
|
|
384
|
+
]
|
|
385
|
+
|
|
386
|
+
_env = {name: os.environ[name] for name in os.environ}
|
|
387
|
+
_env["LANGUAGE"] = self.lang
|
|
388
|
+
for key in self.env:
|
|
389
|
+
value = self.env[key]
|
|
390
|
+
if value:
|
|
391
|
+
_env[key] = environment.expand(value, environment.tempdir, cwd=self.cwd)
|
|
392
|
+
elif key in _env:
|
|
393
|
+
del _env[key]
|
|
394
|
+
|
|
395
|
+
cwd = None if self.linear else self.cwd
|
|
396
|
+
proc: subprocess.CompletedProcess = subprocess.run(
|
|
397
|
+
[environment.target, *expanded], capture_output=True, env=_env, cwd=self.cwd
|
|
398
|
+
)
|
|
399
|
+
returncode: int = proc.returncode
|
|
400
|
+
test_stdout: bytes = proc.stdout
|
|
401
|
+
test_stderr: bytes = proc.stderr
|
|
402
|
+
|
|
403
|
+
for sub_expanded in post_expanded:
|
|
404
|
+
if returncode != 0:
|
|
405
|
+
break
|
|
406
|
+
proc_post: subprocess.CompletedProcess = subprocess.run(
|
|
407
|
+
[environment.target, *sub_expanded],
|
|
408
|
+
capture_output=True,
|
|
409
|
+
env=_env,
|
|
410
|
+
cwd=cwd,
|
|
411
|
+
)
|
|
412
|
+
returncode = proc_post.returncode
|
|
413
|
+
if len(test_stdout) and len(proc_post.stdout):
|
|
414
|
+
test_stdout += b"\n"
|
|
415
|
+
if len(test_stderr) and len(proc_post.stderr):
|
|
416
|
+
test_stderr += b"\n"
|
|
417
|
+
test_stdout += proc_post.stdout
|
|
418
|
+
test_stderr += proc_post.stderr
|
|
419
|
+
|
|
420
|
+
expected_files: list[FileWrite[bytes]] = []
|
|
421
|
+
for key, value in self.writes.items():
|
|
422
|
+
expected_files.append(
|
|
423
|
+
FileWrite(
|
|
424
|
+
generated=load_file_contents(key, environment, cwd=self.cwd),
|
|
425
|
+
template=load_file_contents(value, environment, cwd=self.cwd),
|
|
426
|
+
)
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
clean = self.run_cmds(environment, self.cleanup, environment.tempdir)
|
|
430
|
+
if clean is None:
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
return (
|
|
434
|
+
returncode,
|
|
435
|
+
environment.fix(test_stdout, self.cwd, self.patches),
|
|
436
|
+
environment.fix(test_stderr, self.cwd, self.patches),
|
|
437
|
+
expected_files,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
def clip(self, actual: tuple[int, str, str]) -> str | tuple[int, str, str]:
|
|
441
|
+
result, streams = actual[0], [*actual[1:]]
|
|
442
|
+
if not self.expected:
|
|
443
|
+
return cast(tuple, (result, *streams))
|
|
444
|
+
for ndx in range(len(self.check)):
|
|
445
|
+
check = self.check[ndx]
|
|
446
|
+
if check != "all":
|
|
447
|
+
ex = cast(str, self.expected[ndx + 1])
|
|
448
|
+
if check == "begin":
|
|
449
|
+
streams[ndx] = streams[ndx][: len(ex)]
|
|
450
|
+
elif check == "end":
|
|
451
|
+
streams[ndx] = streams[ndx][-len(ex) :]
|
|
452
|
+
else:
|
|
453
|
+
return check
|
|
454
|
+
return cast(tuple, (result, *streams))
|
|
455
|
+
|
|
456
|
+
@staticmethod
|
|
457
|
+
def text_diff(
|
|
458
|
+
header: str, expected: str, actual: str, pre_mark: str = "", post_mark: str = ""
|
|
459
|
+
):
|
|
460
|
+
return f"""{header}
|
|
461
|
+
Expected:
|
|
462
|
+
{pre_mark}{repr(expected)}{post_mark}
|
|
463
|
+
Actual:
|
|
464
|
+
{pre_mark}{repr(actual)}{post_mark}
|
|
465
|
+
|
|
466
|
+
Diff:
|
|
467
|
+
{_diff(expected, actual)}
|
|
468
|
+
"""
|
|
469
|
+
|
|
470
|
+
def report_io(self, actual: tuple[int, str, str]):
|
|
471
|
+
result = ""
|
|
472
|
+
if not self.expected:
|
|
473
|
+
return result
|
|
474
|
+
|
|
475
|
+
for ndx in range(len(actual)):
|
|
476
|
+
if actual[ndx] == self.expected[ndx]:
|
|
477
|
+
continue
|
|
478
|
+
|
|
479
|
+
if result:
|
|
480
|
+
result += "\n"
|
|
481
|
+
|
|
482
|
+
if ndx:
|
|
483
|
+
check = self.check[ndx - 1]
|
|
484
|
+
pre_mark = "..." if check == "end" else ""
|
|
485
|
+
post_mark = "..." if check == "begin" else ""
|
|
486
|
+
result += Test.text_diff(
|
|
487
|
+
_flds[ndx],
|
|
488
|
+
cast(str, self.expected[ndx]),
|
|
489
|
+
cast(str, actual[ndx]),
|
|
490
|
+
pre_mark,
|
|
491
|
+
post_mark,
|
|
492
|
+
)
|
|
493
|
+
else:
|
|
494
|
+
result += f"""{_flds[ndx]}
|
|
495
|
+
Expected:
|
|
496
|
+
{repr(self.expected[ndx])}
|
|
497
|
+
Actual:
|
|
498
|
+
{repr(actual[ndx])}
|
|
499
|
+
"""
|
|
500
|
+
return result
|
|
501
|
+
|
|
502
|
+
def report_file(
|
|
503
|
+
self,
|
|
504
|
+
file: FileWrite[str],
|
|
505
|
+
):
|
|
506
|
+
header = file.generated.filename
|
|
507
|
+
|
|
508
|
+
if not file.generated.content:
|
|
509
|
+
header += "\n New file was not present to be read"
|
|
510
|
+
if not file.generated.content:
|
|
511
|
+
header += (
|
|
512
|
+
f"\n Test file {file.generated.filename} was not present to be read"
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
if not file.generated.content or not file.template.content:
|
|
516
|
+
return header
|
|
517
|
+
|
|
518
|
+
return Test.text_diff(
|
|
519
|
+
header,
|
|
520
|
+
expected=file.template.content,
|
|
521
|
+
actual=file.generated.content,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
def test_footer(self, env: Env, tempdir: str):
|
|
525
|
+
_env = {}
|
|
526
|
+
_env["LANGUAGE"] = self.lang
|
|
527
|
+
for key in self.env:
|
|
528
|
+
value = self.env[key]
|
|
529
|
+
if value:
|
|
530
|
+
_env[key] = value
|
|
531
|
+
elif key in _env:
|
|
532
|
+
del _env[key]
|
|
533
|
+
if env.reportable_env_prefix:
|
|
534
|
+
for key in os.environ:
|
|
535
|
+
if key.startswith(env.reportable_env_prefix) and key not in _env:
|
|
536
|
+
_env[key] = os.environ[key]
|
|
537
|
+
|
|
538
|
+
expanded = [env.expand(arg, tempdir, cwd=self.cwd) for arg in self.args]
|
|
539
|
+
call = " ".join(
|
|
540
|
+
shlex.quote(arg.replace(os.sep, "/"))
|
|
541
|
+
for arg in [
|
|
542
|
+
*["{}={}".format(key, val) for key, val in _env.items()],
|
|
543
|
+
env.target,
|
|
544
|
+
*expanded,
|
|
545
|
+
]
|
|
546
|
+
)
|
|
547
|
+
return f"{call}\ncwd: {self.cwd}\ntest: {self.filename}"
|
|
548
|
+
|
|
549
|
+
def nullify(self, lang: str | None):
|
|
550
|
+
if lang is not None:
|
|
551
|
+
self.lang = lang
|
|
552
|
+
self.data["lang"] = lang
|
|
553
|
+
self.expected = None
|
|
554
|
+
self.data["expected"] = None
|
|
555
|
+
self.store()
|
|
556
|
+
|
|
557
|
+
def store(self):
|
|
558
|
+
if self.filename.suffix == ".json":
|
|
559
|
+
with self.filename.open("w", encoding="UTF-8") as f:
|
|
560
|
+
json.dump(self.data, f, indent=4, ensure_ascii=False)
|
|
561
|
+
print(file=f)
|
|
562
|
+
return
|
|
563
|
+
with self.filename.open("w", encoding="UTF-8") as f:
|
|
564
|
+
yaml.dump(self.data, stream=f, Dumper=Dumper, width=1024)
|
|
565
|
+
# print(output, file=f, end="")
|
|
566
|
+
|
|
567
|
+
def path(self, filename):
|
|
568
|
+
return os.path.join(self.cwd, filename)
|
|
569
|
+
|
|
570
|
+
def chdir(self, sub):
|
|
571
|
+
self.cwd = os.path.abspath(os.path.join(self.cwd, sub))
|
|
572
|
+
if self.linear:
|
|
573
|
+
os.chdir(self.cwd)
|
|
574
|
+
|
|
575
|
+
def ls(self, sub):
|
|
576
|
+
name = self.path(sub)
|
|
577
|
+
for _, dirnames, filenames in os.walk(name):
|
|
578
|
+
names = sorted(
|
|
579
|
+
[
|
|
580
|
+
*((name.lower(), f"{name}/") for name in dirnames),
|
|
581
|
+
*((name.lower(), f"{name}") for name in filenames),
|
|
582
|
+
]
|
|
583
|
+
)
|
|
584
|
+
dirnames[:] = []
|
|
585
|
+
for _, name in names:
|
|
586
|
+
print(name)
|
|
587
|
+
|
|
588
|
+
def rmtree(self, sub):
|
|
589
|
+
shutil.rmtree(self.path(sub))
|
|
590
|
+
|
|
591
|
+
def cp(self, src: str, dst: str):
|
|
592
|
+
shutil.copy2(self.path(src), self.path(dst))
|
|
593
|
+
|
|
594
|
+
def makedirs(self, sub):
|
|
595
|
+
os.makedirs(self.path(sub), exist_ok=True)
|
|
596
|
+
|
|
597
|
+
def store_output(self, name: str, args: list[str]):
|
|
598
|
+
env = self.current_env
|
|
599
|
+
|
|
600
|
+
if env is not None and args[0] == env.target_name:
|
|
601
|
+
args[0] = env.target
|
|
602
|
+
|
|
603
|
+
proc = subprocess.run(args, shell=False, capture_output=True, cwd=self.cwd)
|
|
604
|
+
self.additional_env[name] = proc.stdout.decode("UTF-8").strip()
|
|
605
|
+
print(f"export {name}={self.additional_env[name]}")
|
|
606
|
+
|
|
607
|
+
@staticmethod
|
|
608
|
+
def load(filename: Path, count: int):
|
|
609
|
+
with open(filename, encoding="UTF-8") as f:
|
|
610
|
+
return Test(yaml.load(f, Loader=Loader), filename, count)
|