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.
- {socx_cli-0.13.2 → socx_cli-0.13.3}/.gitignore +2 -1
- {socx_cli-0.13.2 → socx_cli-0.13.3}/PKG-INFO +1 -1
- {socx_cli-0.13.2 → socx_cli-0.13.3}/pyproject.toml +1 -1
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/regression/regression.py +283 -20
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/regression/test.py +54 -3
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/regression/_run.py +1 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/app.py +11 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/details.py +31 -4
- socx_cli-0.13.3/socx_tui/regression/dialog.py +106 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/tree.py +10 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/widget.py +91 -30
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/static/tcss/regression/app.tcss +2 -0
- socx_cli-0.13.2/socx_tui/regression/dialog.py +0 -51
- {socx_cli-0.13.2 → socx_cli-0.13.3}/LICENSE +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/README.md +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/__main__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/cli/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/cli/_cli.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/cli/_jinja.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/cli/callbacks.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/cli/cfg.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/cli/cli.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/cli/params.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/cli/types.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/config/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/config/_config.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/config/_settings.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/config/converters.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/config/encoders.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/config/formatters.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/config/serializers.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/config/validators.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/_paths.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/encoder.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/enums.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/funcs.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/metadata.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/paths.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/schema/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/schema/git/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/schema/git/git.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/schema/git/manifest.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/schema/plugin.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/schema/types.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/core/serializer.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/git/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/git/_git.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/git/_manifest.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/git/_ssh.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/io/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/io/console.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/io/decorators.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/io/log.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/patterns/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/patterns/mixins/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/patterns/mixins/proxy.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/patterns/mixins/uid.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/patterns/singleton/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/patterns/singleton/singleton.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/patterns/visitor/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/patterns/visitor/protocol.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/patterns/visitor/traversal.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/regression/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/regression/progress.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/regression/status.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/regression/validator.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/regression/visitor.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/static/settings/cli.yaml +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/static/settings/console.yaml +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/static/settings/git.yaml +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/static/settings/plugins.yaml +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/static/settings/regression.yaml +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/static/settings/rich_click.yaml +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/static/settings/settings.yaml +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/static/sql/socx.sql +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/utils/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx/utils/decorators.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/config/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/config/_config.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/config/edit.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/git/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/git/arguments.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/git/callbacks.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/git/cli.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/git/manifest.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/git/renderables.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/git/summary.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/git/utils.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/plugin/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/plugin/example.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/plugin/schema.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/regression/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/regression/callbacks.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/regression/cli.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/regression/run.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/regression/tui.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/version/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_plugins/version/__main__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/__main__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/bindings/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/bindings/vim/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/bindings/vim/mode.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/bindings/vim/vim.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/containers.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/mixins/__init__.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/mixins/composable.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/preview.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/regression/table.py +0 -0
- {socx_cli-0.13.2 → socx_cli-0.13.3}/socx_tui/static/tcss/regression/preview.tcss +0 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
298
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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: '{
|
|
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
|
-
|
|
503
|
+
return Box.from_yaml(filename=str(path))
|
|
333
504
|
case ".toml":
|
|
334
|
-
|
|
505
|
+
return Box.from_toml(filename=str(path))
|
|
335
506
|
case ".json":
|
|
336
|
-
|
|
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
|
-
|
|
342
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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}%)"
|