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.
Files changed (37) hide show
  1. proj_flow/__init__.py +1 -1
  2. proj_flow/api/arg.py +2 -0
  3. proj_flow/api/completers.py +1 -1
  4. proj_flow/api/env.py +45 -15
  5. proj_flow/api/release.py +1 -1
  6. proj_flow/api/step.py +20 -5
  7. proj_flow/{ext/cplusplus/cmake/presets.py → base/cmake_presets.py} +40 -16
  8. proj_flow/base/plugins.py +12 -7
  9. proj_flow/cli/finder.py +4 -3
  10. proj_flow/dependency.py +6 -2
  11. proj_flow/ext/cplusplus/cmake/__init__.py +2 -2
  12. proj_flow/ext/cplusplus/cmake/parser.py +4 -3
  13. proj_flow/ext/cplusplus/cmake/steps.py +9 -9
  14. proj_flow/ext/cplusplus/conan/__init__.py +8 -4
  15. proj_flow/ext/github/cli.py +1 -3
  16. proj_flow/ext/github/publishing.py +1 -0
  17. proj_flow/ext/python/rtdocs.py +37 -8
  18. proj_flow/ext/python/steps.py +5 -4
  19. proj_flow/ext/python/version.py +12 -12
  20. proj_flow/ext/sign/__init__.py +2 -2
  21. proj_flow/ext/test_runner/__init__.py +6 -0
  22. proj_flow/ext/test_runner/cli.py +416 -0
  23. proj_flow/ext/test_runner/driver/__init__.py +2 -0
  24. proj_flow/ext/test_runner/driver/commands.py +74 -0
  25. proj_flow/ext/test_runner/driver/test.py +610 -0
  26. proj_flow/ext/test_runner/driver/testbed.py +141 -0
  27. proj_flow/ext/test_runner/utils/__init__.py +2 -0
  28. proj_flow/ext/test_runner/utils/archives.py +56 -0
  29. proj_flow/log/rich_text/api.py +3 -4
  30. proj_flow/minimal/list.py +126 -10
  31. proj_flow/minimal/run.py +3 -2
  32. proj_flow/project/api.py +1 -0
  33. {proj_flow-0.20.3.dist-info → proj_flow-0.22.0.dist-info}/METADATA +17 -7
  34. {proj_flow-0.20.3.dist-info → proj_flow-0.22.0.dist-info}/RECORD +37 -29
  35. {proj_flow-0.20.3.dist-info → proj_flow-0.22.0.dist-info}/WHEEL +0 -0
  36. {proj_flow-0.20.3.dist-info → proj_flow-0.22.0.dist-info}/entry_points.txt +0 -0
  37. {proj_flow-0.20.3.dist-info → proj_flow-0.22.0.dist-info}/licenses/LICENSE +0 -0
@@ -3,7 +3,7 @@
3
3
 
4
4
  """
5
5
  The **proj_flow.ext.python.rtdocs** defines RTDocs step (`"RTD"`), which uses
6
- .readthedocs.yaml to build the HTML documentation.
6
+ ``.readthedocs.yaml`` to build the HTML documentation.
7
7
  """
8
8
 
9
9
  import os
@@ -20,20 +20,24 @@ from proj_flow.base import cmd
20
20
 
21
21
  @step.register
22
22
  class RTDocs:
23
- name = "RTD"
23
+ """Runs the jobs defined by ``.readthedocs.yaml`` to build the docs"""
24
+
25
+ @property
26
+ def name(self):
27
+ return "RTD"
24
28
 
25
29
  def platform_dependencies(self):
26
30
  return ["python -m PyYAML"]
27
31
 
28
32
  def is_active(self, config: env.Config, rt: env.Runtime) -> bool:
29
- return os.path.isfile(os.path.join(rt.root, ".readthedocs.yaml"))
33
+ return (rt.root / ".readthedocs.yaml").is_file()
30
34
 
31
35
  def run(self, config: env.Config, rt: env.Runtime) -> int:
32
36
  import venv
33
37
 
34
38
  import yaml
35
39
 
36
- with open(os.path.join(rt.root, ".readthedocs.yaml")) as rtd_yaml:
40
+ with open(rt.root / ".readthedocs.yaml") as rtd_yaml:
37
41
  data = yaml.load(rtd_yaml, Loader=yaml.Loader)
38
42
 
39
43
  formats = ["html"]
@@ -100,6 +104,8 @@ class RTDocs:
100
104
 
101
105
 
102
106
  class Builder(ABC):
107
+ """Base class for any recognized builder in the ``.readthedocs.yaml`` config."""
108
+
103
109
  @property
104
110
  @abstractmethod
105
111
  def READTHEDOCS_OUTPUT(self) -> str: ...
@@ -112,17 +118,40 @@ class Builder(ABC):
112
118
 
113
119
 
114
120
  class Sphinx(Builder):
115
- READTHEDOCS_OUTPUT: str = ""
121
+ """Builder used, if Sphinx config is found in the config file.
122
+
123
+ :param config: filename of a Python script Sphinx should use for the configuration
124
+ """
125
+
126
+ @property
127
+ def READTHEDOCS_OUTPUT(self) -> str:
128
+ """A ``build/`` subdirectory placed in the same dir, as ``config`` parameter"""
129
+ return self.output
116
130
 
117
131
  def __init__(self, config: str):
118
132
  self.config = config
119
133
  self.source = os.path.dirname(config)
120
- self.READTHEDOCS_OUTPUT = os.path.join(os.path.dirname(self.source), "build")
134
+ self.output = os.path.join(os.path.dirname(self.source), "build")
121
135
 
122
- def build(self, target: str):
136
+ def build(self, target: str) -> int:
137
+ """Uses ``spinx-build`` to create the documentation.
138
+
139
+ :param target: name of the docs format from YAML config
140
+ :returns: exit code forwarded from the build tool
141
+ """
123
142
  builder = "latex" if target == "pdf" else target
143
+ print(shutil.which("sphinx-build"))
144
+
124
145
  return subprocess.run(
125
- ["sphinx-build", "-M", builder, self.source, self.READTHEDOCS_OUTPUT],
146
+ [
147
+ PYTHON_EXECUTABLE,
148
+ "-m",
149
+ "sphinx.cmd.build",
150
+ "-M",
151
+ builder,
152
+ self.source,
153
+ self.READTHEDOCS_OUTPUT,
154
+ ],
126
155
  shell=False,
127
156
  ).returncode
128
157
 
@@ -22,7 +22,7 @@ class Install:
22
22
  return ["python -m pip"]
23
23
 
24
24
  def run(self, config: env.Config, rt: env.Runtime) -> int:
25
- return rt.cmd("python", "-m", "pip", "install", rt.root)
25
+ return rt.cmd("python", "-m", "pip", "install", rt.root.as_posix())
26
26
 
27
27
 
28
28
  @step.register
@@ -48,8 +48,9 @@ class CheckTwine:
48
48
  return ["twine"]
49
49
 
50
50
  def run(self, config: env.Config, rt: env.Runtime) -> int:
51
- filenames = []
52
- for root, dirnames, filenames in os.walk("dist"):
51
+ filenames: list[str] = []
52
+ root = rt.root / "dist"
53
+ for root, dirnames, filenames in root.walk():
53
54
  dirnames[:] = []
54
55
 
55
56
  _, project = release.project_suites.find(lambda suite: suite.get_project(rt))
@@ -67,5 +68,5 @@ class CheckTwine:
67
68
  return 0
68
69
 
69
70
  return rt.cmd(
70
- "twine", "check", *(os.path.join(root, filename) for filename in filenames)
71
+ "twine", "check", *((root / filename).as_posix() for filename in filenames)
71
72
  )
@@ -7,7 +7,7 @@ The **proj_flow.ext.python.version** provides project suite plugin.
7
7
 
8
8
  import os
9
9
  import re
10
- from typing import NamedTuple, Optional
10
+ from typing import NamedTuple, Optional, cast
11
11
 
12
12
  import toml
13
13
 
@@ -28,8 +28,8 @@ class ProjectSuite(release.ProjectSuite):
28
28
  return None
29
29
 
30
30
  try:
31
- with open(os.path.join(rt.root, path), encoding="UTF-8") as infile:
32
- text = infile.read()
31
+ with open(rt.root / path, encoding="UTF-8") as in_file:
32
+ text = in_file.read()
33
33
  except FileNotFoundError:
34
34
  return None
35
35
 
@@ -63,27 +63,27 @@ class ProjectSuite(release.ProjectSuite):
63
63
  return path
64
64
 
65
65
  def _pyproject_hatch(self, rt: env.Runtime):
66
- pyproject_path = os.path.join(rt.root, "pyproject.toml")
67
66
  try:
68
- data = toml.load(pyproject_path)
69
- project = data.get("project", {})
70
- hatch = data.get("tool", {}).get("hatch", {})
71
- wheels = (
67
+ data = toml.load(rt.root / "pyproject.toml")
68
+ project = cast(dict, data.get("project", {}))
69
+ hatch = cast(dict, data.get("tool", {}).get("hatch", {}))
70
+ wheels = cast(
71
+ dict,
72
72
  hatch.get("build", {})
73
73
  .get("targets", {})
74
74
  .get("wheel", {})
75
- .get("packages", [])
75
+ .get("packages", []),
76
76
  )
77
77
 
78
- name = project.get("name")
78
+ name = cast(str | None, project.get("name"))
79
79
  if len(wheels) > 0:
80
80
  first_wheel = wheels[0].split("/")[-1]
81
81
  if first_wheel:
82
82
  name = first_wheel
83
83
 
84
- dynamic = project.get("dynamic", [])
84
+ dynamic = cast(list[str], project.get("dynamic", []))
85
85
  if "version" in dynamic:
86
- version_dict = hatch.get("version", {})
86
+ version_dict = cast(dict[str, str | None], hatch.get("version", {}))
87
87
  return QuickProjectInfo(
88
88
  name=name,
89
89
  path=version_dict.get("path"),
@@ -54,7 +54,7 @@ class SignBase(step.Step):
54
54
  self._runs_after = runs_after
55
55
  self._runs_before = runs_before
56
56
 
57
- def is_active(self, config: env.Config, rt: env.Runtime) -> int:
57
+ def is_active(self, config: env.Config, rt: env.Runtime) -> bool:
58
58
  self._active_tools = [
59
59
  tool for tool in api.signing_tool.get() if tool.is_active(config, rt)
60
60
  ]
@@ -120,7 +120,7 @@ class SignMsi(SignBase):
120
120
  runs_before=["StorePackages", "Store"],
121
121
  )
122
122
 
123
- def is_active(self, config: env.Config, rt: env.Runtime) -> int:
123
+ def is_active(self, config: env.Config, rt: env.Runtime) -> bool:
124
124
  return super().is_active(config, rt) and "WIX" in config.items.get(
125
125
  "cpack_generator", []
126
126
  )
@@ -0,0 +1,6 @@
1
+ # Copyright (c) 2026 Marcin Zdun
2
+ # This code is licensed under MIT license (see LICENSE for details)
3
+
4
+ from proj_flow.ext.test_runner import cli
5
+
6
+ __all__ = ["cli"]
@@ -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
+ }