proj-flow 0.21.0__py3-none-any.whl → 0.22.1__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 (36) hide show
  1. proj_flow/__init__.py +1 -1
  2. proj_flow/api/completers.py +1 -1
  3. proj_flow/api/env.py +37 -13
  4. proj_flow/api/release.py +1 -1
  5. proj_flow/api/step.py +7 -3
  6. proj_flow/{ext/cplusplus/cmake/presets.py → base/cmake_presets.py} +44 -24
  7. proj_flow/base/plugins.py +12 -7
  8. proj_flow/cli/finder.py +4 -3
  9. proj_flow/dependency.py +6 -2
  10. proj_flow/ext/cplusplus/cmake/__init__.py +2 -2
  11. proj_flow/ext/cplusplus/cmake/parser.py +4 -3
  12. proj_flow/ext/cplusplus/cmake/steps.py +3 -9
  13. proj_flow/ext/cplusplus/conan/__init__.py +6 -4
  14. proj_flow/ext/github/publishing.py +1 -0
  15. proj_flow/ext/python/rtdocs.py +2 -2
  16. proj_flow/ext/python/steps.py +5 -4
  17. proj_flow/ext/python/version.py +12 -12
  18. proj_flow/ext/sign/__init__.py +2 -2
  19. proj_flow/ext/test_runner/__init__.py +6 -0
  20. proj_flow/ext/test_runner/cli.py +416 -0
  21. proj_flow/ext/test_runner/driver/__init__.py +2 -0
  22. proj_flow/ext/test_runner/driver/commands.py +74 -0
  23. proj_flow/ext/test_runner/driver/test.py +610 -0
  24. proj_flow/ext/test_runner/driver/testbed.py +141 -0
  25. proj_flow/ext/test_runner/utils/__init__.py +2 -0
  26. proj_flow/ext/test_runner/utils/archives.py +56 -0
  27. proj_flow/log/release.py +7 -4
  28. proj_flow/log/rich_text/api.py +3 -4
  29. proj_flow/minimal/ext/bug_report.py +6 -4
  30. proj_flow/minimal/ext/versions.py +48 -0
  31. proj_flow/minimal/run.py +3 -2
  32. {proj_flow-0.21.0.dist-info → proj_flow-0.22.1.dist-info}/METADATA +1 -1
  33. {proj_flow-0.21.0.dist-info → proj_flow-0.22.1.dist-info}/RECORD +36 -27
  34. {proj_flow-0.21.0.dist-info → proj_flow-0.22.1.dist-info}/WHEEL +0 -0
  35. {proj_flow-0.21.0.dist-info → proj_flow-0.22.1.dist-info}/entry_points.txt +0 -0
  36. {proj_flow-0.21.0.dist-info → proj_flow-0.22.1.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)