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,416 @@
1
+ # Copyright (c) 2026 Marcin Zdun
2
+ # This code is licensed under MIT license (see LICENSE for details)
3
+
4
+ import concurrent.futures
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ from os import PathLike
11
+ from pathlib import Path
12
+ from typing import Annotated, Any, Generator, cast
13
+
14
+ from proj_flow.api import arg, env, release
15
+ from proj_flow.base.cmake_presets import Presets
16
+ from proj_flow.ext.test_runner.driver.commands import HANDLERS
17
+ from proj_flow.ext.test_runner.driver.test import Env, Test
18
+ from proj_flow.ext.test_runner.driver.testbed import Counters, task
19
+
20
+ RUN_LINEAR = os.environ.get("RUN_LINEAR", 0) != 0
21
+
22
+
23
+ @arg.command("tools", "test-runner")
24
+ def test_runner(
25
+ preset_name: Annotated[
26
+ str,
27
+ arg.Argument(
28
+ help="Set name of CMake build preset",
29
+ meta="CONFIG",
30
+ names=["--preset"],
31
+ ),
32
+ ],
33
+ tests: Annotated[
34
+ str,
35
+ arg.Argument(
36
+ help="Point to directory with the JSON test cases; test cases are enumerated recursively",
37
+ meta="DIR",
38
+ ),
39
+ ],
40
+ version: Annotated[
41
+ str | None,
42
+ arg.Argument(
43
+ help="Select version to patch output with; defaults to automatic detection",
44
+ meta="SEMVER",
45
+ opt=True,
46
+ ),
47
+ ],
48
+ run: Annotated[
49
+ list[str],
50
+ arg.Argument(
51
+ help="Filter the tests to run",
52
+ meta="ID",
53
+ action="extend",
54
+ opt=True,
55
+ nargs="*",
56
+ default=[],
57
+ ),
58
+ ],
59
+ nullify: Annotated[
60
+ bool,
61
+ arg.FlagArgument(
62
+ help='Set the "expected" field of the test cases to null',
63
+ ),
64
+ ],
65
+ rt: env.Runtime,
66
+ ) -> int:
67
+ """Run specified test steps"""
68
+
69
+ if os.name == "nt":
70
+ sys.stdout.reconfigure(encoding="utf-8") # type: ignore
71
+
72
+ if not version:
73
+ proj = release.get_project(rt)
74
+ version = str(proj.version)
75
+
76
+ presets = Presets().visit_file(Path("CMakePresets.json")) or {}
77
+ preset = presets.get(preset_name)
78
+
79
+ if preset is None:
80
+ print(f"error: preset `{preset_name}` not found", file=sys.stderr)
81
+ return 1
82
+
83
+ binary_dir = preset.expand()
84
+ build_type = preset.build_type
85
+
86
+ if not binary_dir:
87
+ print(
88
+ f"error: preset `{preset_name}` has no binaryDir attached to it",
89
+ file=sys.stderr,
90
+ )
91
+ return 1
92
+
93
+ if not build_type:
94
+ print(
95
+ f"error: preset `{preset_name}` has no CMAKE_BUILD_TYPE attached to it",
96
+ file=sys.stderr,
97
+ )
98
+ return 1
99
+
100
+ config = cast(dict, rt._cfg.get("test-runner", {}))
101
+ target = cast(str | None, config.get("target"))
102
+
103
+ if not isinstance(target, str):
104
+ print(
105
+ "error: cannot find test target; "
106
+ "please add name of the executable as `target' property of "
107
+ "`test-runner' config in flow's configuration file",
108
+ file=sys.stderr,
109
+ )
110
+ return 1
111
+
112
+ ext = ".exe" if sys.platform == "win32" else ""
113
+ target_path = Path(binary_dir) / "bin" / (target + ext)
114
+ if not target_path.is_file():
115
+ print(
116
+ f"error: cannot find {target + ext} in {target_path.parent.as_posix()}",
117
+ file=sys.stderr,
118
+ )
119
+ return 1
120
+
121
+ install_components = cast(list[str], config.get("install", []))
122
+ patches = cast(dict[str, str], config.get("patches"))
123
+ env_prefix = cast(str | None, config.get("report_env"))
124
+
125
+ testsuite_config = cast(dict, config.get("testsuite", {}))
126
+ test_root = cast(str | None, testsuite_config.get("root"))
127
+ test_root_path = Path(test_root).resolve() if test_root else None
128
+ if not test_root_path:
129
+ print(
130
+ "error: cannot find test root directory; "
131
+ "please add name of the directory as `root' property of "
132
+ "`test-runner/testsuite' config in flow's configuration file",
133
+ file=sys.stderr,
134
+ )
135
+ return 1
136
+
137
+ test_data_dir = cast(str | None, testsuite_config.get("data"))
138
+ data_dir = (
139
+ (test_root_path / test_data_dir).resolve()
140
+ if isinstance(test_data_dir, str)
141
+ else None
142
+ )
143
+
144
+ test_default_set = cast(str | None, testsuite_config.get("default-set"))
145
+ if isinstance(test_default_set, str):
146
+ if (
147
+ not (test_root_path / tests).is_dir()
148
+ and (test_root_path / test_default_set / tests).is_dir()
149
+ ):
150
+ tests = f"{test_default_set}/{tests}"
151
+
152
+ test_set_dir = test_root_path / tests
153
+
154
+ test_files = _enum_tests(test_set_dir, data_dir)
155
+ tests_to_run = [int(x) for s in (run or []) for x in s.split(",")]
156
+ if not tests_to_run:
157
+ tests_to_run = list(range(1, len(test_files) + 1))
158
+
159
+ independent_tests, linear_tests = _load_tests(test_files, tests_to_run)
160
+
161
+ if nullify:
162
+ for sequence in (independent_tests, linear_tests):
163
+ for test in sequence:
164
+ test[0].nullify(lang=None)
165
+ return 0
166
+
167
+ if not independent_tests and not linear_tests:
168
+ print("No tests to run.", file=sys.stderr)
169
+ return 0
170
+
171
+ env = _make_env(
172
+ target_path,
173
+ data_dir or Path(binary_dir),
174
+ version,
175
+ len(independent_tests) + len(linear_tests),
176
+ patches,
177
+ env_prefix,
178
+ )
179
+
180
+ print("target: ", env.target, env.version)
181
+ if env.data_dir_alt is None:
182
+ print("data: ", env.data_dir)
183
+ else:
184
+ print("data: ", env.data_dir, env.data_dir_alt)
185
+ print("tests: ", tests)
186
+ if env.tempdir_alt is None:
187
+ print("$TEMP: ", env.tempdir)
188
+ else:
189
+ print("$TEMP: ", env.tempdir, env.tempdir_alt)
190
+
191
+ os.makedirs(env.tempdir, exist_ok=True)
192
+
193
+ install_dir = Path("build").resolve() / ".test-runner"
194
+ if not _install(
195
+ install_dir,
196
+ binary_dir,
197
+ build_type,
198
+ install_components,
199
+ env,
200
+ ):
201
+ return 1
202
+
203
+ return _run_and_report_tests(
204
+ independent_tests=independent_tests,
205
+ linear_tests=linear_tests,
206
+ install_dir=install_dir,
207
+ env=env,
208
+ )
209
+
210
+
211
+ def _run_and_report_tests(
212
+ independent_tests: list[tuple[Test, int]],
213
+ linear_tests: list[tuple[Test, int]],
214
+ install_dir: Path,
215
+ env: Env,
216
+ ):
217
+ RUN_LINEAR = os.environ.get("RUN_LINEAR", 0) != 0
218
+ if RUN_LINEAR:
219
+ linear_tests[0:0] = independent_tests
220
+ independent_tests = []
221
+
222
+ counters = Counters()
223
+
224
+ if independent_tests:
225
+ try:
226
+ thread_count = int(os.environ.get("POOL_SIZE", "not-a-number"))
227
+ except (ValueError, TypeError):
228
+ thread_count = max(1, (os.cpu_count() or 0)) * 2
229
+ print("threads:", thread_count)
230
+
231
+ _report_tests(counters, _run_async_tests(independent_tests, env, thread_count))
232
+
233
+ _report_tests(counters, _run_sync_tests(linear_tests, env))
234
+
235
+ shutil.rmtree(install_dir, ignore_errors=True)
236
+ shutil.rmtree("build/.testing", ignore_errors=True)
237
+
238
+ if not counters.summary(len(independent_tests) + len(linear_tests)):
239
+ return 1
240
+
241
+ return 0
242
+
243
+
244
+ def _run_async_tests(tests: list[tuple[Test, int]], env: Env, thread_count: int):
245
+ with concurrent.futures.ThreadPoolExecutor(thread_count) as executor:
246
+ futures: list[concurrent.futures.Future[tuple[int, str, str | None, str]]] = []
247
+ for test, counter in tests:
248
+ futures.append(executor.submit(task, env, test, counter))
249
+
250
+ for future in concurrent.futures.as_completed(futures):
251
+ yield future.result()
252
+
253
+
254
+ def _run_sync_tests(tests: list[tuple[Test, int]], env: Env):
255
+ for test, counter in tests:
256
+ yield task(env, test, counter)
257
+
258
+
259
+ def _report_tests(
260
+ counters: Counters,
261
+ results: Generator[tuple[int, str, str | None, str], Any, None],
262
+ ):
263
+ for outcome, test_id, message, tempdir in results:
264
+ counters.report(outcome, test_id, message)
265
+ shutil.rmtree(tempdir, ignore_errors=True)
266
+
267
+
268
+ def _enum_tests(test_root: Path, data_dir: Path | None):
269
+ test_files: list[Path] = []
270
+ for root, _, files in test_root.walk():
271
+ for filename in files:
272
+ if not Path(filename).suffix in [".json", ".yaml", ".yml"]:
273
+ continue
274
+ test_path = root / filename
275
+ if data_dir and test_path.is_relative_to(data_dir):
276
+ continue
277
+ test_files.append(test_path)
278
+
279
+ return test_files
280
+
281
+
282
+ def _load_tests(testsuite: list[Path], run: list[int]):
283
+ counter: int = 0
284
+ independent_tests: list[tuple[Test, int]] = []
285
+ linear_tests: list[tuple[Test, int]] = []
286
+
287
+ for filename in sorted(testsuite):
288
+ counter += 1
289
+ if counter not in run:
290
+ continue
291
+
292
+ try:
293
+ test = Test.load(filename, counter)
294
+ if not test.ok:
295
+ continue
296
+ except Exception:
297
+ print(">>>>>>", filename.as_posix(), file=sys.stderr)
298
+ raise
299
+
300
+ if test.linear:
301
+ linear_tests.append((test, counter))
302
+ else:
303
+ independent_tests.append((test, counter))
304
+
305
+ return independent_tests, linear_tests
306
+
307
+
308
+ def _cmake_executable(binary_dir: str | PathLike[str]):
309
+ cache = Path(binary_dir) / "CMakeCache.txt"
310
+ if not cache.is_file():
311
+ return "cmake"
312
+
313
+ with cache.open(encoding="UTF-8") as cache_file:
314
+ for line in cache_file:
315
+ line = line.strip()
316
+ if not line.startswith("CMAKE_COMMAND:INTERNAL="):
317
+ continue
318
+ return line.split("=", 1)[1]
319
+
320
+ return "cmake"
321
+
322
+
323
+ def _run_with(test: Test, *args: str):
324
+ cwd = None if test.linear else test.cwd
325
+ subprocess.run(args, shell=False, cwd=cwd)
326
+
327
+
328
+ def _target(target: str):
329
+ def impl(test: Test, aditional: list[str]):
330
+ _run_with(test, target, *aditional)
331
+
332
+ return impl
333
+
334
+
335
+ def _make_env(
336
+ target: Path,
337
+ data_dir: Path,
338
+ version: str,
339
+ counter_total: int,
340
+ patches: dict[str, str],
341
+ env_prefix: str | None,
342
+ ):
343
+ target_name = target.stem if os.name == "nt" else target.name
344
+ tempdir = (Path(tempfile.gettempdir()) / "test-runner").resolve()
345
+ tempdir_alt = None
346
+ data_dir_alt = None
347
+
348
+ if os.sep != "/":
349
+ tempdir_alt = str(tempdir)
350
+ data_dir_alt = str(data_dir)
351
+
352
+ length = counter_total
353
+ digits = 1
354
+ while length > 9:
355
+ digits += 1
356
+ length = length // 10
357
+
358
+ return Env(
359
+ target=str(target),
360
+ target_name=target_name,
361
+ build_dir=str(target.parent.parent),
362
+ data_dir=data_dir.as_posix(),
363
+ inst_dir=str(data_dir.parent / "copy" / "bin"),
364
+ tempdir=tempdir.as_posix(),
365
+ version=version,
366
+ counter_digits=digits,
367
+ counter_total=counter_total,
368
+ handlers=HANDLERS,
369
+ data_dir_alt=data_dir_alt,
370
+ tempdir_alt=tempdir_alt,
371
+ builtin_patches=patches,
372
+ reportable_env_prefix=env_prefix,
373
+ )
374
+
375
+
376
+ def _install(
377
+ dst: str | PathLike[str],
378
+ binary_dir: str | PathLike[str],
379
+ build_type: str,
380
+ components: list[str],
381
+ env: Env,
382
+ ):
383
+ dst = Path(dst)
384
+ shutil.rmtree(dst, ignore_errors=True)
385
+ dst.mkdir(parents=True, exist_ok=True)
386
+
387
+ args = [
388
+ _cmake_executable(binary_dir),
389
+ "--install",
390
+ str(binary_dir),
391
+ "--config",
392
+ build_type,
393
+ "--prefix",
394
+ str(dst),
395
+ ]
396
+
397
+ if not components:
398
+ proc = subprocess.run(args, capture_output=True)
399
+ return proc.returncode == 0
400
+
401
+ args.append("--component")
402
+ args.append("")
403
+
404
+ for component in components:
405
+ args[-1] = component
406
+ proc = subprocess.run(args, capture_output=True)
407
+ if proc.returncode != 0:
408
+ return False
409
+
410
+ env.target = str(dst / "bin" / os.path.basename(env.target))
411
+ target_name = os.path.split(env.target)[1]
412
+ if os.name == "nt":
413
+ target_name = os.path.splitext(target_name)[0]
414
+ env.handlers[target_name] = (0, _target(env.target))
415
+
416
+ return True
@@ -0,0 +1,2 @@
1
+ # Copyright (c) 2026 Marcin Zdun
2
+ # This code is licensed under MIT license (see LICENSE for details)
@@ -0,0 +1,74 @@
1
+ # Copyright (c) 2026 Marcin Zdun
2
+ # This code is licensed under MIT license (see LICENSE for details)
3
+
4
+ import os
5
+ import stat
6
+ import subprocess
7
+ import sys
8
+ from typing import Callable
9
+
10
+ from proj_flow.ext.test_runner.driver import test
11
+ from proj_flow.ext.test_runner.utils.archives import locate_unpack
12
+
13
+ _file_cache = {}
14
+ _rw_mask = stat.S_IWRITE | stat.S_IWGRP | stat.S_IWOTH
15
+ _ro_mask = 0o777 ^ _rw_mask
16
+
17
+
18
+ def _touch(test: test.Test, args: list[str]):
19
+ filename = test.path(args[0])
20
+ os.makedirs(os.path.dirname(filename), exist_ok=True)
21
+ with open(filename, "wb") as f:
22
+ if len(args) > 1:
23
+ f.write(args[1].encode("UTF-8"))
24
+
25
+
26
+ def _make_RO(test: test.Test, args: list[str]):
27
+ filename = test.path(args[0])
28
+ mode = os.stat(filename).st_mode
29
+ _file_cache[filename] = mode
30
+ os.chmod(filename, mode & _ro_mask)
31
+
32
+
33
+ def _make_RW(test: test.Test, args: list[str]):
34
+ filename = test.path(args[0])
35
+ try:
36
+ mode = _file_cache[filename]
37
+ except KeyError:
38
+ mode = os.stat(filename).st_mode | _rw_mask
39
+ os.chmod(filename, mode)
40
+
41
+
42
+ def _unpack(test: test.Test, args: list[str]):
43
+ archive = args[0]
44
+ dst = args[1]
45
+ unpack = locate_unpack(archive)[0]
46
+ unpack(archive, test.path(dst))
47
+
48
+
49
+ def _cat(test: test.Test, args: list[str]):
50
+ filename = args[0]
51
+ with open(test.path(filename)) as f:
52
+ sys.stdout.write(f.read())
53
+
54
+
55
+ def _shell(test: test.Test, args: list[str]):
56
+ print("shell!!!")
57
+ print("target:", test.current_env.target if test.current_env is not None else "?")
58
+ subprocess.call("pwsh" if os.name == "nt" else "bash", shell=True, cwd=test.cwd)
59
+
60
+
61
+ HANDLERS: dict[str, tuple[int, Callable[[test.Test, list[str]], None]]] = {
62
+ "mkdirs": (1, lambda test, args: test.makedirs(args[0])),
63
+ "cd": (1, lambda test, args: test.chdir(args[0])),
64
+ "rm": (1, lambda test, args: test.rmtree(args[0])),
65
+ "touch": (1, _touch),
66
+ "unpack": (2, _unpack),
67
+ "store": (2, lambda test, args: test.store_output(args[0], args[1:])),
68
+ "ro": (1, _make_RO),
69
+ "cp": (2, lambda test, args: test.cp(args[0], args[1])),
70
+ "rw": (1, _make_RW),
71
+ "ls": (1, lambda test, args: test.ls(args[0])),
72
+ "cat": (1, _cat),
73
+ "shell": (0, _shell),
74
+ }