socx-cli 0.13.2__tar.gz → 0.13.3__tar.gz

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 (113) hide show
  1. {socx_cli-0.13.2 → socx_cli-0.13.3}/.gitignore +2 -1
  2. {socx_cli-0.13.2 → socx_cli-0.13.3}/PKG-INFO +1 -1
  3. {socx_cli-0.13.2 → socx_cli-0.13.3}/pyproject.toml +1 -1
  4. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/regression/regression.py +283 -20
  5. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/regression/test.py +54 -3
  6. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/regression/_run.py +1 -0
  7. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/app.py +11 -0
  8. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/details.py +31 -4
  9. socx_cli-0.13.3/socx_tui/regression/dialog.py +106 -0
  10. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/tree.py +10 -0
  11. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/widget.py +91 -30
  12. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/static/tcss/regression/app.tcss +2 -0
  13. socx_cli-0.13.2/socx_tui/regression/dialog.py +0 -51
  14. {socx_cli-0.13.2 → socx_cli-0.13.3}/LICENSE +0 -0
  15. {socx_cli-0.13.2 → socx_cli-0.13.3}/README.md +0 -0
  16. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/__init__.py +0 -0
  17. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/__main__.py +0 -0
  18. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/cli/__init__.py +0 -0
  19. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/cli/_cli.py +0 -0
  20. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/cli/_jinja.py +0 -0
  21. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/cli/callbacks.py +0 -0
  22. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/cli/cfg.py +0 -0
  23. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/cli/cli.py +0 -0
  24. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/cli/params.py +0 -0
  25. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/cli/types.py +0 -0
  26. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/config/__init__.py +0 -0
  27. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/config/_config.py +0 -0
  28. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/config/_settings.py +0 -0
  29. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/config/converters.py +0 -0
  30. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/config/encoders.py +0 -0
  31. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/config/formatters.py +0 -0
  32. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/config/serializers.py +0 -0
  33. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/config/validators.py +0 -0
  34. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/__init__.py +0 -0
  35. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/_paths.py +0 -0
  36. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/encoder.py +0 -0
  37. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/enums.py +0 -0
  38. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/funcs.py +0 -0
  39. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/metadata.py +0 -0
  40. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/paths.py +0 -0
  41. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/schema/__init__.py +0 -0
  42. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/schema/git/__init__.py +0 -0
  43. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/schema/git/git.py +0 -0
  44. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/schema/git/manifest.py +0 -0
  45. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/schema/plugin.py +0 -0
  46. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/schema/types.py +0 -0
  47. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/serializer.py +0 -0
  48. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/git/__init__.py +0 -0
  49. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/git/_git.py +0 -0
  50. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/git/_manifest.py +0 -0
  51. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/git/_ssh.py +0 -0
  52. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/io/__init__.py +0 -0
  53. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/io/console.py +0 -0
  54. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/io/decorators.py +0 -0
  55. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/io/log.py +0 -0
  56. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/patterns/__init__.py +0 -0
  57. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/patterns/mixins/__init__.py +0 -0
  58. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/patterns/mixins/proxy.py +0 -0
  59. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/patterns/mixins/uid.py +0 -0
  60. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/patterns/singleton/__init__.py +0 -0
  61. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/patterns/singleton/singleton.py +0 -0
  62. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/patterns/visitor/__init__.py +0 -0
  63. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/patterns/visitor/protocol.py +0 -0
  64. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/patterns/visitor/traversal.py +0 -0
  65. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/regression/__init__.py +0 -0
  66. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/regression/progress.py +0 -0
  67. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/regression/status.py +0 -0
  68. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/regression/validator.py +0 -0
  69. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/regression/visitor.py +0 -0
  70. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/static/settings/cli.yaml +0 -0
  71. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/static/settings/console.yaml +0 -0
  72. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/static/settings/git.yaml +0 -0
  73. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/static/settings/plugins.yaml +0 -0
  74. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/static/settings/regression.yaml +0 -0
  75. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/static/settings/rich_click.yaml +0 -0
  76. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/static/settings/settings.yaml +0 -0
  77. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/static/sql/socx.sql +0 -0
  78. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/utils/__init__.py +0 -0
  79. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/utils/decorators.py +0 -0
  80. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/config/__init__.py +0 -0
  81. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/config/_config.py +0 -0
  82. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/config/edit.py +0 -0
  83. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/git/__init__.py +0 -0
  84. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/git/arguments.py +0 -0
  85. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/git/callbacks.py +0 -0
  86. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/git/cli.py +0 -0
  87. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/git/manifest.py +0 -0
  88. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/git/renderables.py +0 -0
  89. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/git/summary.py +0 -0
  90. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/git/utils.py +0 -0
  91. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/plugin/__init__.py +0 -0
  92. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/plugin/example.py +0 -0
  93. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/plugin/schema.py +0 -0
  94. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/regression/__init__.py +0 -0
  95. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/regression/callbacks.py +0 -0
  96. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/regression/cli.py +0 -0
  97. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/regression/run.py +0 -0
  98. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/regression/tui.py +0 -0
  99. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/version/__init__.py +0 -0
  100. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/version/__main__.py +0 -0
  101. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/__init__.py +0 -0
  102. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/__init__.py +0 -0
  103. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/__main__.py +0 -0
  104. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/bindings/__init__.py +0 -0
  105. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/bindings/vim/__init__.py +0 -0
  106. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/bindings/vim/mode.py +0 -0
  107. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/bindings/vim/vim.py +0 -0
  108. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/containers.py +0 -0
  109. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/mixins/__init__.py +0 -0
  110. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/mixins/composable.py +0 -0
  111. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/preview.py +0 -0
  112. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/table.py +0 -0
  113. {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/static/tcss/regression/preview.tcss +0 -0
@@ -40,7 +40,6 @@
40
40
  !*.yaml
41
41
  !*.toml
42
42
  !*.json
43
- !*.example
44
43
 
45
44
  !LICENSE
46
45
  !Makefile
@@ -50,3 +49,5 @@
50
49
  !CHANGELOG.md
51
50
  !CONTRIBUTING.md
52
51
  !CODE_OF_CONDUCT.md
52
+
53
+ workrun/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: socx-cli
3
- Version: 0.13.2
3
+ Version: 0.13.3
4
4
  Summary: System on chip verification and tooling infrastructure.
5
5
  Project-URL: Issues, https://github.com/sagikimhi/socx-cli/issues
6
6
  Project-URL: Homepage, https://sagikimhi.dev/socx-cli
@@ -29,7 +29,7 @@ socx = 'socx.__main__:main'
29
29
  [project]
30
30
  name = "socx-cli"
31
31
  readme = "README.md"
32
- version = "0.13.2"
32
+ version = "0.13.3"
33
33
  license = "Apache-2.0"
34
34
  authors = [{ name = "Sagi Kimhi", email = "sagi.kim5@gmail.com" }]
35
35
  maintainers = [{ name = "Sagi Kimhi", email = "sagi.kim5@gmail.com" }]
@@ -5,9 +5,10 @@ from __future__ import annotations
5
5
  import asyncio as aio
6
6
  import anyio
7
7
  import logging
8
+ import re
8
9
  import time
9
10
  from collections import OrderedDict
10
- from collections.abc import AsyncGenerator, Iterable
11
+ from collections.abc import AsyncGenerator, Iterable, Mapping
11
12
  from pathlib import Path
12
13
  from threading import RLock
13
14
  from typing import Self, Any, Annotated
@@ -36,6 +37,25 @@ logger = logging.getLogger(__name__)
36
37
  semaphore = anyio.Semaphore(max(1, settings.regression.max_runs_in_parallel))
37
38
 
38
39
 
40
+ def _safe_dir_name(name: str, node_id: UUID4) -> str:
41
+ slug = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("-").lower()
42
+ return f"{slug or 'item'}-{node_id}"
43
+
44
+
45
+ def _coerce_status(value: TestStatus | int | str) -> TestStatus:
46
+ if isinstance(value, TestStatus):
47
+ return value
48
+ if isinstance(value, int):
49
+ return TestStatus(value)
50
+ return TestStatus[value.strip().lower().title()]
51
+
52
+
53
+ def _coerce_result(value: TestResult | str) -> TestResult:
54
+ if isinstance(value, TestResult):
55
+ return value
56
+ return TestResult(value)
57
+
58
+
39
59
  class Regression(TestBase):
40
60
  """Manage and execute a collection of tests with concurrency control."""
41
61
 
@@ -78,7 +98,28 @@ class Regression(TestBase):
78
98
  test_cls: type[TestBase] | None = None,
79
99
  **kwargs: Any,
80
100
  ) -> Self:
81
- return cls._from_file(path, test_cls=test_cls, **kwargs)
101
+ return cls._from_file(path, name=name, test_cls=test_cls, **kwargs)
102
+
103
+ @classmethod
104
+ @validate_call()
105
+ def load(
106
+ cls,
107
+ path: str | Path,
108
+ name: str | None = None,
109
+ test_cls: type[TestBase] | None = None,
110
+ **kwargs: Any,
111
+ ) -> Self:
112
+ path = TypeAdapter(FilePath).validate_python(path)
113
+ data = cls._read_data(path)
114
+
115
+ if cls._looks_like_state(data):
116
+ return cls._from_state_data(
117
+ data,
118
+ output_dir=path.parent,
119
+ test_cls=test_cls or Test,
120
+ )
121
+
122
+ return cls._from_file(path, name=name, test_cls=test_cls, **kwargs)
82
123
 
83
124
  @computed_field
84
125
  @property
@@ -187,7 +228,7 @@ class Regression(TestBase):
187
228
  self._done = aio.Queue()
188
229
  self._pending = aio.Queue()
189
230
  self.finished_time = None
190
- self.started_time = time.perf_counter()
231
+ self.started_time = time.time()
191
232
  logger.info("regression starting...")
192
233
 
193
234
  try:
@@ -196,7 +237,7 @@ class Regression(TestBase):
196
237
  for _ in range(self.run_limit):
197
238
  tg.start_soon(self._runner)
198
239
  finally:
199
- self.finished_time = time.perf_counter()
240
+ self.finished_time = time.time()
200
241
  self._pause_event.set()
201
242
  self._running.clear()
202
243
  logger.info(f"regression {self.status.name.lower()}.")
@@ -294,24 +335,146 @@ class Regression(TestBase):
294
335
  self.pending.task_done()
295
336
  semaphore.release()
296
337
 
297
- def dump_state(self, output_dir: Path) -> None:
298
- """Write the regression command results to their respective files."""
338
+ def assign_output_dir(self, output_dir: Path) -> Path:
339
+ self.output_dir = output_dir
340
+ output_dir.mkdir(parents=True, exist_ok=True)
341
+
342
+ for child in self.tests:
343
+ child_output_dir = output_dir / _safe_dir_name(
344
+ child.name, child.id
345
+ )
346
+ if isinstance(child, Regression):
347
+ child.assign_output_dir(child_output_dir)
348
+ else:
349
+ child.output_dir = child_output_dir
350
+
351
+ return output_dir
352
+
353
+ def dump_state(self, output_dir: Path | None = None) -> Path:
354
+ """Write the regression state and test artifacts to disk."""
355
+ root_output_dir = self.output_dir
356
+ if output_dir is not None:
357
+ root_output_dir = self.assign_output_dir(output_dir / self.name)
358
+
359
+ if root_output_dir is None:
360
+ msg = "Regression output directory is not configured."
361
+ raise ValueError(msg)
362
+
299
363
  logger.info("saving regression state and results to disk...")
300
- file = output_dir / self.name / "state.yaml"
301
- state = self.model_dump(
302
- mode="json",
303
- round_trip=True,
304
- serialize_as_any=True,
305
- include={"result", "status"},
306
- )
364
+ self._persist_test_outputs()
365
+ file = root_output_dir / "state.yaml"
366
+ state = self._serialize_state(root_output_dir)
307
367
  file.parent.mkdir(parents=True, exist_ok=True)
308
- file.touch(exist_ok=False)
309
368
  box.DDBox(state).to_yaml(str(file))
310
- logger.info(f"state and results saved to: '{output_dir}'.")
369
+ logger.info(f"state and results saved to: '{file}'.")
370
+ return file
311
371
 
312
372
  def _active_tests(self) -> list[TestBase]:
313
373
  return [test for test in self.tests if test.id in self._running]
314
374
 
375
+ def iter_leaf_tests(self) -> Iterable[TestBase]:
376
+ for test in self.tests:
377
+ if isinstance(test, Regression):
378
+ yield from test.iter_leaf_tests()
379
+ else:
380
+ yield test
381
+
382
+ @property
383
+ def leaf_tests(self) -> list[TestBase]:
384
+ return list(self.iter_leaf_tests())
385
+
386
+ @property
387
+ def total_test_count(self) -> int:
388
+ return len(self.leaf_tests)
389
+
390
+ @property
391
+ def completed_test_count(self) -> int:
392
+ return sum(
393
+ 1
394
+ for test in self.iter_leaf_tests()
395
+ if test.status in (TestStatus.Finished, TestStatus.Terminated)
396
+ )
397
+
398
+ @property
399
+ def progress_ratio(self) -> float:
400
+ total = self.total_test_count
401
+ if total == 0:
402
+ return 0.0
403
+ return min(1.0, self.completed_test_count / total)
404
+
405
+ @property
406
+ def estimated_remaining_time(self) -> float | None:
407
+ total = self.total_test_count
408
+ completed = self.completed_test_count
409
+ elapsed = self.elapsed_time
410
+
411
+ if total == 0 or elapsed is None:
412
+ return None
413
+ if completed >= total:
414
+ return 0.0
415
+ if completed == 0 or elapsed <= 0:
416
+ return None
417
+
418
+ rate = completed / elapsed
419
+ if rate <= 0:
420
+ return None
421
+
422
+ return max(0.0, (total - completed) / rate)
423
+
424
+ def _persist_test_outputs(self) -> None:
425
+ for child in self.tests:
426
+ if isinstance(child, Regression):
427
+ child._persist_test_outputs()
428
+ continue
429
+
430
+ if (
431
+ isinstance(child, Test)
432
+ and child.started_time is not None
433
+ and child.output_dir is not None
434
+ ):
435
+ child._prepare_output_files()
436
+ child._write_output_files()
437
+
438
+ def _serialize_state(self, root_output_dir: Path) -> dict[str, Any]:
439
+ return self._serialize_node(self, root_output_dir)
440
+
441
+ @classmethod
442
+ def _serialize_node(
443
+ cls, node: TestBase, root_output_dir: Path
444
+ ) -> dict[str, Any]:
445
+ state = {
446
+ "kind": "regression" if isinstance(node, Regression) else "test",
447
+ "id": str(node.id),
448
+ "name": node.name,
449
+ "started_time": node.started_time,
450
+ "finished_time": node.finished_time,
451
+ "status": node.status.name.lower(),
452
+ "result": node.result.value,
453
+ }
454
+
455
+ if node.output_dir is not None and node.output_dir != root_output_dir:
456
+ state["output_dir"] = str(
457
+ node.output_dir.relative_to(root_output_dir)
458
+ )
459
+
460
+ if isinstance(node, Regression):
461
+ state["tests"] = [
462
+ cls._serialize_node(child, root_output_dir)
463
+ for child in node.tests
464
+ ]
465
+ return state
466
+
467
+ state["exec"] = str(node.exec) if node.exec is not None else None
468
+ if node.stdout_path is not None and node.stdout_path.exists():
469
+ state["stdout_path"] = str(
470
+ node.stdout_path.relative_to(root_output_dir)
471
+ )
472
+ if node.stderr_path is not None and node.stderr_path.exists():
473
+ state["stderr_path"] = str(
474
+ node.stderr_path.relative_to(root_output_dir)
475
+ )
476
+ return state
477
+
315
478
  @classmethod
316
479
  @validate_call()
317
480
  def _from_file(
@@ -326,20 +489,29 @@ class Regression(TestBase):
326
489
  path = TypeAdapter(FilePath).validate_python(path)
327
490
  name = name or path.stem
328
491
  test_cls = test_cls or Test
492
+ data = cls._read_data(path)
493
+
494
+ settings.update(Box({name: data}), merge=False)
495
+ return cls._from_data(name, settings[name], test_cls)
496
+
497
+ @staticmethod
498
+ def _read_data(path: Path) -> Mapping[str, Any]:
499
+ from box import Box
329
500
 
330
501
  match path.suffix.lower():
331
502
  case ".yml" | ".yaml":
332
- data = Box.from_yaml(filename=str(path))
503
+ return Box.from_yaml(filename=str(path))
333
504
  case ".toml":
334
- data = Box.from_toml(filename=str(path))
505
+ return Box.from_toml(filename=str(path))
335
506
  case ".json":
336
- data = Box.from_json(filename=str(path))
507
+ return Box.from_json(filename=str(path))
337
508
  case _:
338
509
  msg = f"Unsupported file format: '{path.suffix}'"
339
510
  raise ValueError(msg)
340
511
 
341
- settings.update(Box({name: data}), merge=False)
342
- return cls._from_data(name, settings[name], test_cls)
512
+ @staticmethod
513
+ def _looks_like_state(data: Mapping[str, Any]) -> bool:
514
+ return data.get("kind") == "regression" and "tests" in data
343
515
 
344
516
  @classmethod
345
517
  def _from_data(
@@ -366,6 +538,97 @@ class Regression(TestBase):
366
538
  regressions.append(regression)
367
539
  return cls(name=name, tests=regressions)
368
540
 
541
+ @classmethod
542
+ def _from_state_data(
543
+ cls,
544
+ data: Mapping[str, Any],
545
+ output_dir: Path,
546
+ test_cls: type[TestBase],
547
+ ) -> Self:
548
+ node = cls._deserialize_node(
549
+ data,
550
+ root_output_dir=output_dir,
551
+ test_cls=test_cls,
552
+ parent_output_dir=None,
553
+ )
554
+ if not isinstance(node, Regression):
555
+ msg = "State file must contain a root regression."
556
+ raise ValueError(msg)
557
+ return node
558
+
559
+ @classmethod
560
+ def _deserialize_node(
561
+ cls,
562
+ data: Mapping[str, Any],
563
+ root_output_dir: Path,
564
+ test_cls: type[TestBase],
565
+ parent_output_dir: Path | None,
566
+ ) -> TestBase:
567
+ kind = str(data.get("kind", "")).strip().lower()
568
+ output_dir = cls._resolve_output_dir(
569
+ data,
570
+ root_output_dir=root_output_dir,
571
+ parent_output_dir=parent_output_dir,
572
+ )
573
+
574
+ if kind == "regression":
575
+ regression = cls(
576
+ id=data["id"],
577
+ name=data["name"],
578
+ started_time=data.get("started_time"),
579
+ finished_time=data.get("finished_time"),
580
+ tests=[],
581
+ )
582
+ regression.output_dir = output_dir
583
+ regression.tests = [
584
+ cls._deserialize_node(
585
+ child,
586
+ root_output_dir=root_output_dir,
587
+ test_cls=test_cls,
588
+ parent_output_dir=regression.output_dir,
589
+ )
590
+ for child in data.get("tests", [])
591
+ ]
592
+ return regression
593
+
594
+ test = test_cls(
595
+ id=data["id"],
596
+ name=data["name"],
597
+ exec=data.get("exec"),
598
+ started_time=data.get("started_time"),
599
+ finished_time=data.get("finished_time"),
600
+ )
601
+ test.output_dir = output_dir
602
+ test.status = _coerce_status(data.get("status", TestStatus.Idle))
603
+ test.result = _coerce_result(data.get("result", TestResult.NA))
604
+
605
+ if isinstance(test, Test):
606
+ for attr, relpath in (
607
+ ("stdout", data.get("stdout_path")),
608
+ ("stderr", data.get("stderr_path")),
609
+ ):
610
+ if relpath:
611
+ file = root_output_dir / str(relpath)
612
+ if file.exists():
613
+ setattr(test, attr, file.read_text(encoding="utf-8"))
614
+
615
+ return test
616
+
617
+ @classmethod
618
+ def _resolve_output_dir(
619
+ cls,
620
+ data: Mapping[str, Any],
621
+ *,
622
+ root_output_dir: Path,
623
+ parent_output_dir: Path | None,
624
+ ) -> Path:
625
+ relative_output_dir = data.get("output_dir")
626
+ if relative_output_dir:
627
+ return root_output_dir / str(relative_output_dir)
628
+ if parent_output_dir is None:
629
+ return root_output_dir
630
+ return parent_output_dir / _safe_dir_name(data["name"], data["id"])
631
+
369
632
 
370
633
  TreeNode = Annotated[
371
634
  TestBase,
@@ -8,6 +8,7 @@ import signal
8
8
  import time
9
9
  import uuid
10
10
  from enum import StrEnum, IntEnum, auto
11
+ from pathlib import Path
11
12
 
12
13
  from pydantic import (
13
14
  BaseModel,
@@ -59,6 +60,7 @@ class TestBase(BaseModel):
59
60
  _status: TestStatus = PrivateAttr(TestStatus.Idle)
60
61
  _process: aio.subprocess.Process | None = PrivateAttr(default=None)
61
62
  _termination_requested: bool = PrivateAttr(default=False)
63
+ _output_dir: Path | None = PrivateAttr(default=None)
62
64
 
63
65
  @computed_field
64
66
  @property
@@ -84,12 +86,41 @@ class TestBase(BaseModel):
84
86
  return None
85
87
  return Process(self._process.pid)
86
88
 
89
+ @property
90
+ def output_dir(self) -> Path | None:
91
+ return self._output_dir
92
+
93
+ @output_dir.setter
94
+ def output_dir(self, value: Path | None) -> None:
95
+ self._output_dir = value
96
+
97
+ @property
98
+ def stdout_path(self) -> Path | None:
99
+ if self.output_dir is None:
100
+ return None
101
+ return self.output_dir / "stdout.txt"
102
+
103
+ @property
104
+ def stderr_path(self) -> Path | None:
105
+ if self.output_dir is None:
106
+ return None
107
+ return self.output_dir / "stderr.txt"
108
+
87
109
  @computed_field
88
110
  @property
89
111
  def started(self) -> bool:
90
112
  """Return ``True`` once ``start`` has spawned the subprocess."""
91
113
  return self.status > TestStatus.Pending
92
114
 
115
+ @property
116
+ def elapsed_time(self) -> float | None:
117
+ """Return the elapsed runtime derived from wall-clock timestamps."""
118
+ if self.started_time is None:
119
+ return None
120
+
121
+ end_time = self.finished_time or time.time()
122
+ return max(0.0, end_time - self.started_time)
123
+
93
124
  @property
94
125
  def finished(self) -> bool:
95
126
  """Return ``True`` if the test completed and recorded a result."""
@@ -188,6 +219,15 @@ class TestBase(BaseModel):
188
219
  self.reset()
189
220
  await self.start()
190
221
 
222
+ def _prepare_output_files(self) -> None:
223
+ if self.output_dir is None:
224
+ return
225
+
226
+ self.output_dir.mkdir(parents=True, exist_ok=True)
227
+ for path in (self.stdout_path, self.stderr_path):
228
+ if path is not None:
229
+ path.write_text("", encoding="utf-8")
230
+
191
231
 
192
232
  class Test(TestBase):
193
233
  """Concrete test model with subprocess execution support."""
@@ -237,14 +277,16 @@ class Test(TestBase):
237
277
  self.result = TestResult.NA
238
278
  self.stdout = ""
239
279
  self.stderr = ""
240
- self.started_time = time.perf_counter()
280
+ self.started_time = time.time()
241
281
  self.finished_time = None
242
282
  self.status = TestStatus.Pending
283
+ self._prepare_output_files()
243
284
 
244
285
  if not self.exec:
245
286
  self.status = TestStatus.Terminated
246
287
  self.result = TestResult.Failed
247
- self.finished_time = time.perf_counter()
288
+ self.finished_time = time.time()
289
+ self._write_output_files()
248
290
  return
249
291
 
250
292
  process = await aio.create_subprocess_exec(
@@ -263,9 +305,10 @@ class Test(TestBase):
263
305
  try:
264
306
  stdout, stderr = await process.communicate()
265
307
  finally:
266
- self.finished_time = time.perf_counter()
308
+ self.finished_time = time.time()
267
309
  self.stderr = stderr.decode() if stderr else ""
268
310
  self.stdout = stdout.decode() if stdout else ""
311
+ self._write_output_files()
269
312
  returncode = process.returncode or 0
270
313
 
271
314
  if self._termination_requested or returncode < 0:
@@ -279,3 +322,11 @@ class Test(TestBase):
279
322
  self.result = TestResult.Failed
280
323
 
281
324
  self._process = None
325
+
326
+ def _write_output_files(self) -> None:
327
+ for path, content in (
328
+ (self.stdout_path, self.stdout),
329
+ (self.stderr_path, self.stderr),
330
+ ):
331
+ if path is not None:
332
+ path.write_text(content, encoding="utf-8")
@@ -219,6 +219,7 @@ async def run_regression(
219
219
  path_in = file or _get_input_path()
220
220
  regression = populate_regression(path_in)
221
221
  output_dir = _get_output_path(regression)
222
+ regression.assign_output_dir(output_dir / regression.name)
222
223
  names_set = _get_names_to_run()
223
224
  progress = RegressionProgress(regression)
224
225
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from contextlib import suppress
5
6
  from typing import Any, ClassVar
6
7
  from collections import ChainMap
7
8
  from collections.abc import Iterable
@@ -38,6 +39,16 @@ class SoCX(App[int]):
38
39
  kwargs = dict(ChainMap(kwargs, dict(inline=True)))
39
40
  return super().run(*args, **kwargs)
40
41
 
42
+ def exit(
43
+ self,
44
+ result: int | None = None,
45
+ return_code: int = 0,
46
+ message: Any | None = None,
47
+ ) -> None:
48
+ with suppress(Exception):
49
+ self.regression.persist_loaded_regression_state()
50
+ super().exit(result=result, return_code=return_code, message=message)
51
+
41
52
  def compose(self) -> ComposeResult:
42
53
  """Lay out the application chrome shared between all screens."""
43
54
  yield Header(
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from datetime import datetime
3
4
  from textwrap import wrap
4
5
  from typing import ClassVar
5
6
 
@@ -79,9 +80,11 @@ class RegressionDetails(Widget, can_focus=True, inherit_bindings=True):
79
80
  f"**✅ Passed:** {self.count_results(model, TestResult.Passed)}", # noqa: E501
80
81
  f"**💡 Status:** {self.format_status(model.status)}",
81
82
  f"**🚩 Result:** {self.format_result(model.result)}",
82
- # f"**⌛ Elapsed Time:** {self.format_timedelta(model.time_elapsed)}", # noqa: E501, W505
83
+ f"**⌛ Elapsed Time:** {self.format_timedelta(model.elapsed_time)}", # noqa: E501
83
84
  f"**⌛ Started Time:** {self.format_time(model.started_time)}", # noqa: E501
84
85
  f"**⌛ Finished Time:** {self.format_time(model.finished_time)}", # noqa: E501
86
+ f"**📊 Progress:** `{self.format_progress(model)}`",
87
+ f"**⏳ ETA:** {self.format_timedelta(model.estimated_remaining_time)}", # noqa: E501
85
88
  ]
86
89
  )
87
90
  else:
@@ -89,7 +92,7 @@ class RegressionDetails(Widget, can_focus=True, inherit_bindings=True):
89
92
  [
90
93
  f"**💡 Status:** {self.format_status(model.status)}",
91
94
  f"**🚩 Result:** {self.format_result(model.result)}",
92
- # f"**⌛ Elapsed Time:** {self.format_timedelta(model.time_elapsed)}", # noqa: E501, W505
95
+ f"**⌛ Elapsed Time:** {self.format_timedelta(model.elapsed_time)}", # noqa: E501
93
96
  f"**⌛ Started Time:** {self.format_time(model.started_time)}", # noqa: E501
94
97
  f"**⌛ Finished Time:** {self.format_time(model.finished_time)}", # noqa: E501
95
98
  "",
@@ -123,7 +126,20 @@ class RegressionDetails(Widget, can_focus=True, inherit_bindings=True):
123
126
  return status.name.lower()
124
127
 
125
128
  def format_time(self, value: float | None) -> str:
126
- return "n/a" if value is None else f"{value:.3f}"
129
+ if value is None:
130
+ return "n/a"
131
+
132
+ try:
133
+ if value >= 946684800:
134
+ return (
135
+ datetime.fromtimestamp(value)
136
+ .astimezone()
137
+ .strftime("%Y-%m-%d %H:%M:%S %Z")
138
+ )
139
+ except (OverflowError, OSError, ValueError):
140
+ pass
141
+
142
+ return f"{value:.3f}s"
127
143
 
128
144
  def format_timedelta(self, value: int | float | None) -> str:
129
145
  if value is None:
@@ -135,4 +151,15 @@ class RegressionDetails(Widget, can_focus=True, inherit_bindings=True):
135
151
  return f"{hours:02}h:{minutes:02}m:{seconds:02}s"
136
152
 
137
153
  def count_results(self, regression: Regression, result: TestResult) -> int:
138
- return sum(1 for test in regression.tests if test.result is result)
154
+ return sum(
155
+ 1 for test in regression.iter_leaf_tests() if test.result is result
156
+ )
157
+
158
+ def format_progress(self, regression: Regression, width: int = 24) -> str:
159
+ total = regression.total_test_count
160
+ completed = regression.completed_test_count
161
+ ratio = regression.progress_ratio
162
+ filled = min(width, round(ratio * width))
163
+ bar = "#" * filled + "-" * (width - filled)
164
+ percent = int(ratio * 100)
165
+ return f"[{bar}] {completed}/{total} ({percent}%)"