socx-cli 0.13.11__tar.gz → 0.13.13__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.11 → socx_cli-0.13.13}/PKG-INFO +2 -2
- {socx_cli-0.13.11 → socx_cli-0.13.13}/pyproject.toml +2 -2
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/__init__.py +4 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/__init__.py +4 -0
- socx_cli-0.13.13/socx/core/loader.py +19 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/schema/types.py +4 -1
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/regression/__init__.py +2 -0
- socx_cli-0.13.13/socx/regression/loader.py +564 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/regression/progress.py +27 -15
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/regression/regression.py +37 -49
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/regression/test.py +36 -45
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/static/settings/git.yaml +0 -2
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/static/settings/regression.yaml +7 -2
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/regression/_run.py +4 -4
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/regression/callbacks.py +1 -1
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/widget.py +2 -4
- {socx_cli-0.13.11 → socx_cli-0.13.13}/.gitignore +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/LICENSE +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/README.md +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/__main__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/cli/__init__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/cli/_cli.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/cli/_jinja.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/cli/callbacks.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/cli/cfg.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/cli/cli.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/cli/params.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/cli/types.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/config/__init__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/config/_config.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/config/_settings.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/config/converters.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/config/encoders.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/config/formatters.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/config/serializers.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/config/validators.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/_paths.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/encoder.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/enums.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/funcs.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/metadata.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/paths.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/schema/__init__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/schema/git/__init__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/schema/git/git.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/schema/git/manifest.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/schema/plugin.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/serializer.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/git/__init__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/git/_git.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/git/_manifest.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/git/_ssh.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/io/__init__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/io/console.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/io/decorators.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/io/log.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/patterns/__init__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/patterns/mixins/__init__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/patterns/mixins/proxy.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/patterns/mixins/uid.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/patterns/singleton/__init__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/patterns/singleton/singleton.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/patterns/visitor/__init__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/patterns/visitor/protocol.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/patterns/visitor/traversal.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/regression/status.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/regression/validator.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/regression/visitor.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/static/settings/cli.yaml +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/static/settings/console.yaml +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/static/settings/logging.yaml +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/static/settings/plugins.yaml +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/static/settings/rich_click.yaml +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/static/settings/settings.yaml +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/static/sql/socx.sql +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/utils/__init__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/utils/decorators.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/config/__init__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/config/_config.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/config/edit.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/git/__init__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/git/arguments.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/git/callbacks.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/git/cli.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/git/manifest.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/git/renderables.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/git/summary.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/git/utils.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/plugin/__init__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/plugin/example.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/plugin/schema.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/regression/__init__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/regression/cli.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/regression/run.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/regression/serve.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/regression/tui.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/version/__init__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/version/__main__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/__init__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/__init__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/__main__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/app.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/bindings/__init__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/bindings/vim/__init__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/bindings/vim/mode.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/bindings/vim/vim.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/containers.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/details.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/dialog.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/mixins/__init__.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/mixins/composable.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/mixins/configurable.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/preview.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/table.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/tree.py +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/static/tcss/regression/app.tcss +0 -0
- {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/static/tcss/regression/preview.tcss +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: socx-cli
|
|
3
|
-
Version: 0.13.
|
|
3
|
+
Version: 0.13.13
|
|
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
|
|
@@ -30,7 +30,7 @@ Classifier: Topic :: Utilities
|
|
|
30
30
|
Requires-Python: >=3.12
|
|
31
31
|
Requires-Dist: anyio[trio]>=4.12.1
|
|
32
32
|
Requires-Dist: blinker>=1.9.0
|
|
33
|
-
Requires-Dist: click
|
|
33
|
+
Requires-Dist: click<8.4.0
|
|
34
34
|
Requires-Dist: copier~=9.11
|
|
35
35
|
Requires-Dist: declare
|
|
36
36
|
Requires-Dist: dynaconf<3.2.12
|
|
@@ -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.13"
|
|
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" }]
|
|
@@ -58,7 +58,7 @@ dependencies = [
|
|
|
58
58
|
"uv",
|
|
59
59
|
"pip",
|
|
60
60
|
"rich",
|
|
61
|
-
"click",
|
|
61
|
+
"click<8.4.0",
|
|
62
62
|
"declare",
|
|
63
63
|
"typer~=0.19",
|
|
64
64
|
"jinja2",
|
|
@@ -38,6 +38,7 @@ __all__ = (
|
|
|
38
38
|
"APP_CONFIG_FILENAME",
|
|
39
39
|
"Encoder",
|
|
40
40
|
"Serializer",
|
|
41
|
+
"Loader",
|
|
41
42
|
"wrap",
|
|
42
43
|
"desync",
|
|
43
44
|
"deduplicate",
|
|
@@ -133,6 +134,7 @@ __all__ = (
|
|
|
133
134
|
"Test",
|
|
134
135
|
"TestBase",
|
|
135
136
|
"Regression",
|
|
137
|
+
"RegressionLoader",
|
|
136
138
|
"TestStatus",
|
|
137
139
|
"TestResult",
|
|
138
140
|
"RegressionProgress",
|
|
@@ -174,6 +176,7 @@ from socx.core import APP_CONFIG_FILE as APP_CONFIG_FILE
|
|
|
174
176
|
from socx.core import APP_CONFIG_FILENAME as APP_CONFIG_FILENAME
|
|
175
177
|
from socx.core import Encoder as Encoder
|
|
176
178
|
from socx.core import Serializer as Serializer
|
|
179
|
+
from socx.core import Loader as Loader
|
|
177
180
|
from socx.core import wrap as wrap
|
|
178
181
|
from socx.core import desync as desync
|
|
179
182
|
from socx.core import deduplicate as deduplicate
|
|
@@ -257,6 +260,7 @@ from socx.patterns import ProxyMixin as ProxyMixin
|
|
|
257
260
|
from socx.regression import Test as Test
|
|
258
261
|
from socx.regression import TestBase as TestBase
|
|
259
262
|
from socx.regression import Regression as Regression
|
|
263
|
+
from socx.regression import RegressionLoader as RegressionLoader
|
|
260
264
|
from socx.regression import TestStatus as TestStatus
|
|
261
265
|
from socx.regression import TestResult as TestResult
|
|
262
266
|
from socx.regression import RegressionProgress as RegressionProgress
|
|
@@ -49,6 +49,8 @@ __all__ = (
|
|
|
49
49
|
"Encoder",
|
|
50
50
|
# serializer
|
|
51
51
|
"Serializer",
|
|
52
|
+
# loader
|
|
53
|
+
"Loader",
|
|
52
54
|
)
|
|
53
55
|
|
|
54
56
|
|
|
@@ -100,3 +102,5 @@ from socx.core.paths import APP_CONFIG_FILENAME as APP_CONFIG_FILENAME
|
|
|
100
102
|
from socx.core.encoder import Encoder as Encoder
|
|
101
103
|
|
|
102
104
|
from socx.core.serializer import Serializer as Serializer
|
|
105
|
+
|
|
106
|
+
from socx.core.loader import Loader as Loader
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Base abstractions for loading domain models from external sources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Loader[T](ABC):
|
|
11
|
+
"""Base class for model loaders."""
|
|
12
|
+
|
|
13
|
+
def __call__(self, path: str | Path, *args: Any, **kwargs: Any) -> T:
|
|
14
|
+
return self.load(path, *args, **kwargs)
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def load(self, path: str | Path, *args: Any, **kwargs: Any) -> T:
|
|
18
|
+
"""Load an object from ``path``."""
|
|
19
|
+
...
|
|
@@ -219,7 +219,7 @@ def validate_script(value: str | Iterable[str]) -> str:
|
|
|
219
219
|
return ""
|
|
220
220
|
|
|
221
221
|
if isinstance(value, list | tuple):
|
|
222
|
-
value = "\n".join(
|
|
222
|
+
value = "\n".join(value)
|
|
223
223
|
|
|
224
224
|
script = value if isinstance(value, str) else "\n".join(value)
|
|
225
225
|
|
|
@@ -240,5 +240,8 @@ class Model(BaseModel):
|
|
|
240
240
|
model_config = ConfigDict(
|
|
241
241
|
extra="allow",
|
|
242
242
|
from_attributes=True,
|
|
243
|
+
validate_by_name=True,
|
|
244
|
+
validate_by_alias=True,
|
|
243
245
|
arbitrary_types_allowed=True,
|
|
246
|
+
use_attribute_docstrings=True,
|
|
244
247
|
)
|
|
@@ -6,6 +6,7 @@ __all__ = (
|
|
|
6
6
|
"TestStatus",
|
|
7
7
|
"TestResult",
|
|
8
8
|
"Regression",
|
|
9
|
+
"RegressionLoader",
|
|
9
10
|
"RegressionProgress",
|
|
10
11
|
)
|
|
11
12
|
|
|
@@ -14,4 +15,5 @@ from socx.regression.test import TestBase as TestBase
|
|
|
14
15
|
from socx.regression.test import TestStatus as TestStatus
|
|
15
16
|
from socx.regression.test import TestResult as TestResult
|
|
16
17
|
from socx.regression.regression import Regression as Regression
|
|
18
|
+
from socx.regression.loader import RegressionLoader as RegressionLoader
|
|
17
19
|
from socx.regression.progress import RegressionProgress as RegressionProgress
|
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
"""Load regression definitions and saved states from configuration files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import tomllib
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Self, cast, ClassVar, TYPE_CHECKING
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
|
|
13
|
+
from jinja2 import Environment, Undefined
|
|
14
|
+
|
|
15
|
+
from socx.config import SymbolConverter, settings
|
|
16
|
+
from socx.core.loader import Loader
|
|
17
|
+
from socx.regression.test import Test, TestBase
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
_converter = SymbolConverter()
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from socx.regression.regression import Regression
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PreservingUndefined(Undefined):
|
|
29
|
+
"""Keep unresolved Jinja variables available for later render passes."""
|
|
30
|
+
|
|
31
|
+
def __getattr__(self, name: str) -> Self:
|
|
32
|
+
if name.startswith("__"):
|
|
33
|
+
raise AttributeError(name)
|
|
34
|
+
return self._derive(name)
|
|
35
|
+
|
|
36
|
+
def __getitem__(self, key: Any) -> Self: # ty:ignore[invalid-method-override]
|
|
37
|
+
return self._derive(str(key))
|
|
38
|
+
|
|
39
|
+
def __str__(self) -> str:
|
|
40
|
+
name = self._undefined_name or ""
|
|
41
|
+
return "{{ %s }}" % name
|
|
42
|
+
|
|
43
|
+
def _derive(self, name: str) -> Self:
|
|
44
|
+
base = self._undefined_name
|
|
45
|
+
if base:
|
|
46
|
+
name = f"{base}.{name}"
|
|
47
|
+
return type(self)(
|
|
48
|
+
hint=self._undefined_hint,
|
|
49
|
+
obj=self._undefined_obj,
|
|
50
|
+
name=name,
|
|
51
|
+
exc=self._undefined_exception,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RegressionLoader(Loader["Regression"]):
|
|
56
|
+
"""Build ``Regression`` instances from YAML, TOML, or JSON files."""
|
|
57
|
+
|
|
58
|
+
context_key = "context"
|
|
59
|
+
defaults_key = "defaults"
|
|
60
|
+
tests_key = "tests"
|
|
61
|
+
default_context_keys: ClassVar[set[str]] = {
|
|
62
|
+
"command",
|
|
63
|
+
"count",
|
|
64
|
+
"cwd",
|
|
65
|
+
"env",
|
|
66
|
+
"exec",
|
|
67
|
+
"fresh_env",
|
|
68
|
+
"script",
|
|
69
|
+
"seed",
|
|
70
|
+
"seeds",
|
|
71
|
+
"timeout",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
test_cls: str | type[Test] | None = None,
|
|
77
|
+
regression_cls: str | type[Regression] | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
from socx.regression.regression import Regression
|
|
80
|
+
|
|
81
|
+
test_cls = test_cls or settings.regression.get("test_cls", Test)
|
|
82
|
+
regression_cls = regression_cls or settings.regression.get(
|
|
83
|
+
"regression_cls", Regression
|
|
84
|
+
)
|
|
85
|
+
self.test_cls = self._resolve_symbol(test_cls, Test)
|
|
86
|
+
self.regression_cls = self._resolve_symbol(regression_cls, Regression)
|
|
87
|
+
self._env = Environment(
|
|
88
|
+
undefined=PreservingUndefined,
|
|
89
|
+
keep_trailing_newline=True,
|
|
90
|
+
lstrip_blocks=True,
|
|
91
|
+
trim_blocks=True,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def load(
|
|
95
|
+
self,
|
|
96
|
+
path: str | Path,
|
|
97
|
+
name: str | None = None,
|
|
98
|
+
**kwargs: Any,
|
|
99
|
+
) -> Regression:
|
|
100
|
+
"""Load a regression definition or saved regression state."""
|
|
101
|
+
path = self._validate_file(path)
|
|
102
|
+
raw = path.read_text(encoding="utf-8")
|
|
103
|
+
context = self.extract_context(path, raw)
|
|
104
|
+
rendered = self.render_text(raw, context)
|
|
105
|
+
data = self.parse_text(path, rendered)
|
|
106
|
+
|
|
107
|
+
if self.regression_cls._looks_like_state(data):
|
|
108
|
+
return self.regression_cls._from_state_data(
|
|
109
|
+
data,
|
|
110
|
+
output_dir=path.parent,
|
|
111
|
+
test_cls=self.test_cls,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return self.from_data(
|
|
115
|
+
data,
|
|
116
|
+
name=name or path.stem,
|
|
117
|
+
context=context,
|
|
118
|
+
**kwargs,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def from_file(
|
|
122
|
+
self,
|
|
123
|
+
path: str | Path,
|
|
124
|
+
name: str | None = None,
|
|
125
|
+
**kwargs: Any,
|
|
126
|
+
) -> Regression:
|
|
127
|
+
"""Load a regression definition from a configuration file."""
|
|
128
|
+
path = self._validate_file(path)
|
|
129
|
+
raw = path.read_text(encoding="utf-8")
|
|
130
|
+
context = self.extract_context(path, raw)
|
|
131
|
+
rendered = self.render_text(raw, context)
|
|
132
|
+
data = self.parse_text(path, rendered)
|
|
133
|
+
return self.from_data(
|
|
134
|
+
data,
|
|
135
|
+
name=name or path.stem,
|
|
136
|
+
context=context,
|
|
137
|
+
**kwargs,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def from_data(
|
|
141
|
+
self,
|
|
142
|
+
data: Mapping[str, Any],
|
|
143
|
+
name: str,
|
|
144
|
+
context: Mapping[str, Any] | None = None,
|
|
145
|
+
defaults: Mapping[str, Any] | None = None,
|
|
146
|
+
**kwargs: Any,
|
|
147
|
+
) -> Regression:
|
|
148
|
+
"""Build a regression tree from already parsed definition data."""
|
|
149
|
+
data = dict(data)
|
|
150
|
+
data.pop(self.context_key, None)
|
|
151
|
+
context = dict(context or {})
|
|
152
|
+
defaults = dict(defaults or {})
|
|
153
|
+
global_defaults = self._default_fields_from_context(context)
|
|
154
|
+
inherited_defaults = global_defaults | defaults
|
|
155
|
+
|
|
156
|
+
if self._is_regression_spec(data):
|
|
157
|
+
return self._build_regression(
|
|
158
|
+
name,
|
|
159
|
+
data,
|
|
160
|
+
context=context,
|
|
161
|
+
defaults=inherited_defaults,
|
|
162
|
+
**kwargs,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
tests: list[TestBase] = []
|
|
166
|
+
for child_name, child_data in data.items():
|
|
167
|
+
tests.append(
|
|
168
|
+
self._build_node(
|
|
169
|
+
str(child_name),
|
|
170
|
+
child_data,
|
|
171
|
+
context=context,
|
|
172
|
+
defaults=inherited_defaults,
|
|
173
|
+
**kwargs,
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return self.regression_cls(name=name, tests=tests, **kwargs)
|
|
178
|
+
|
|
179
|
+
def extract_context(self, path: str | Path, raw: str) -> dict[str, Any]:
|
|
180
|
+
"""Extract top-level ``context`` before rendering the full file."""
|
|
181
|
+
path = Path(path)
|
|
182
|
+
match path.suffix.lower():
|
|
183
|
+
case ".yml" | ".yaml":
|
|
184
|
+
data = self._extract_yaml_context(raw)
|
|
185
|
+
case ".toml":
|
|
186
|
+
data = self._extract_toml_context(raw)
|
|
187
|
+
case ".json":
|
|
188
|
+
data = self._extract_json_context(raw)
|
|
189
|
+
case _:
|
|
190
|
+
msg = f"Unsupported file format: '{path.suffix}'"
|
|
191
|
+
raise ValueError(msg)
|
|
192
|
+
|
|
193
|
+
return cast(dict[str, Any], self._render_value(data, data))
|
|
194
|
+
|
|
195
|
+
def render_text(
|
|
196
|
+
self, text: str, context: Mapping[str, Any] | None = None
|
|
197
|
+
) -> str:
|
|
198
|
+
context = self._render_context(context or {})
|
|
199
|
+
template = self._env.from_string(text)
|
|
200
|
+
return template.render(self._template_context(context))
|
|
201
|
+
|
|
202
|
+
def parse_text(self, path: str | Path, text: str) -> dict[str, Any]:
|
|
203
|
+
path = Path(path)
|
|
204
|
+
match path.suffix.lower():
|
|
205
|
+
case ".yml" | ".yaml":
|
|
206
|
+
from ruamel.yaml import YAML
|
|
207
|
+
|
|
208
|
+
data = YAML(typ="safe").load(text) or {}
|
|
209
|
+
case ".toml":
|
|
210
|
+
data = tomllib.loads(text)
|
|
211
|
+
case ".json":
|
|
212
|
+
data = json.loads(text)
|
|
213
|
+
case _:
|
|
214
|
+
msg = f"Unsupported file format: '{path.suffix}'"
|
|
215
|
+
raise ValueError(msg)
|
|
216
|
+
|
|
217
|
+
if not isinstance(data, Mapping):
|
|
218
|
+
msg = f"Regression file must contain a mapping: '{path}'"
|
|
219
|
+
raise ValueError(msg)
|
|
220
|
+
|
|
221
|
+
return dict(data)
|
|
222
|
+
|
|
223
|
+
def _build_node(
|
|
224
|
+
self,
|
|
225
|
+
name: str,
|
|
226
|
+
data: Any,
|
|
227
|
+
*,
|
|
228
|
+
context: Mapping[str, Any],
|
|
229
|
+
defaults: Mapping[str, Any],
|
|
230
|
+
**kwargs: Any,
|
|
231
|
+
) -> TestBase:
|
|
232
|
+
if isinstance(data, list):
|
|
233
|
+
return self._build_regression(
|
|
234
|
+
name,
|
|
235
|
+
{self.tests_key: data},
|
|
236
|
+
context=context,
|
|
237
|
+
defaults=defaults,
|
|
238
|
+
**kwargs,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
if not isinstance(data, Mapping):
|
|
242
|
+
msg = f"Regression '{name}' must be a list or mapping."
|
|
243
|
+
raise ValueError(msg)
|
|
244
|
+
|
|
245
|
+
if self._is_regression_spec(data):
|
|
246
|
+
return self._build_regression(
|
|
247
|
+
name,
|
|
248
|
+
data,
|
|
249
|
+
context=context,
|
|
250
|
+
defaults=defaults,
|
|
251
|
+
**kwargs,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return self.from_data(
|
|
255
|
+
data,
|
|
256
|
+
name=name,
|
|
257
|
+
context=context,
|
|
258
|
+
defaults=defaults,
|
|
259
|
+
**kwargs,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def _build_regression(
|
|
263
|
+
self,
|
|
264
|
+
name: str,
|
|
265
|
+
data: Mapping[str, Any],
|
|
266
|
+
*,
|
|
267
|
+
context: Mapping[str, Any],
|
|
268
|
+
defaults: Mapping[str, Any],
|
|
269
|
+
**kwargs: Any,
|
|
270
|
+
) -> Regression:
|
|
271
|
+
local_defaults = dict(defaults)
|
|
272
|
+
local_defaults.update(
|
|
273
|
+
cast(Mapping[str, Any], data.get(self.defaults_key, {}))
|
|
274
|
+
)
|
|
275
|
+
tests = [
|
|
276
|
+
test
|
|
277
|
+
for item in data.get(self.tests_key, [])
|
|
278
|
+
for test in self._build_tests(item, context, local_defaults)
|
|
279
|
+
]
|
|
280
|
+
return self.regression_cls(name=name, tests=tests, **kwargs)
|
|
281
|
+
|
|
282
|
+
def _build_tests(
|
|
283
|
+
self,
|
|
284
|
+
data: Mapping[str, Any],
|
|
285
|
+
context: Mapping[str, Any],
|
|
286
|
+
defaults: Mapping[str, Any],
|
|
287
|
+
) -> list[Test]:
|
|
288
|
+
if not isinstance(data, Mapping):
|
|
289
|
+
msg = "Regression tests must be defined as mappings."
|
|
290
|
+
raise ValueError(msg)
|
|
291
|
+
|
|
292
|
+
raw = dict(defaults) | dict(data)
|
|
293
|
+
base_name = str(raw.get("name", "")).strip()
|
|
294
|
+
if not base_name:
|
|
295
|
+
msg = "Regression test is missing required field 'name'."
|
|
296
|
+
raise ValueError(msg)
|
|
297
|
+
|
|
298
|
+
count = int(raw.get("count", 1))
|
|
299
|
+
seeds = self._get_seeds(raw, count)
|
|
300
|
+
tests: list[Test] = []
|
|
301
|
+
|
|
302
|
+
for run_index in range(count):
|
|
303
|
+
run_name = (
|
|
304
|
+
f"{base_name}_run_{run_index + 1}" if count > 1 else base_name
|
|
305
|
+
)
|
|
306
|
+
seed = seeds[run_index] if seeds is not None else raw.get("seed")
|
|
307
|
+
run_context = (
|
|
308
|
+
dict(context)
|
|
309
|
+
| raw
|
|
310
|
+
| {
|
|
311
|
+
"index": run_index,
|
|
312
|
+
"name": base_name,
|
|
313
|
+
"run_index": run_index,
|
|
314
|
+
"run_name": run_name,
|
|
315
|
+
"run_number": run_index + 1,
|
|
316
|
+
"seed": seed,
|
|
317
|
+
"settings": settings,
|
|
318
|
+
"this": settings,
|
|
319
|
+
}
|
|
320
|
+
)
|
|
321
|
+
payload = self._render_value(raw, run_context)
|
|
322
|
+
payload.pop("seeds", None)
|
|
323
|
+
payload["name"] = run_name
|
|
324
|
+
payload["count"] = 1
|
|
325
|
+
if seed is not None:
|
|
326
|
+
payload["seed"] = seed
|
|
327
|
+
tests.append(self.test_cls(**payload))
|
|
328
|
+
|
|
329
|
+
return tests
|
|
330
|
+
|
|
331
|
+
def _get_seeds(
|
|
332
|
+
self, data: Mapping[str, Any], count: int
|
|
333
|
+
) -> list[Any] | None:
|
|
334
|
+
value = data.get("seed", data.get("seeds"))
|
|
335
|
+
if value is None:
|
|
336
|
+
return None
|
|
337
|
+
if isinstance(value, list | tuple):
|
|
338
|
+
if not value:
|
|
339
|
+
value = [0]
|
|
340
|
+
|
|
341
|
+
if len(value) < count:
|
|
342
|
+
value.extend([value[-1] for _ in range(count - len(value))])
|
|
343
|
+
|
|
344
|
+
return list(value[:count])
|
|
345
|
+
|
|
346
|
+
return [value for _ in range(count)]
|
|
347
|
+
|
|
348
|
+
def _render_context(self, context: Mapping[str, Any]) -> dict[str, Any]:
|
|
349
|
+
rv = dict(context)
|
|
350
|
+
template_context = self._template_context(rv)
|
|
351
|
+
return cast(dict[str, Any], self._render_value(rv, template_context))
|
|
352
|
+
|
|
353
|
+
def _render_value(self, value: Any, context: Mapping[str, Any]) -> Any:
|
|
354
|
+
if isinstance(value, str):
|
|
355
|
+
if value.startswith("@jinja"):
|
|
356
|
+
value = value.removeprefix("@jinja").lstrip()
|
|
357
|
+
value = self._env.from_string(value).render(
|
|
358
|
+
self._template_context(context)
|
|
359
|
+
)
|
|
360
|
+
return self._parse_conf_value(value)
|
|
361
|
+
|
|
362
|
+
if isinstance(value, Mapping):
|
|
363
|
+
return {
|
|
364
|
+
key: self._render_value(val, context)
|
|
365
|
+
for key, val in value.items()
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if isinstance(value, list):
|
|
369
|
+
return [self._render_value(item, context) for item in value]
|
|
370
|
+
|
|
371
|
+
return value
|
|
372
|
+
|
|
373
|
+
def _parse_conf_value(self, value: str) -> Any:
|
|
374
|
+
if not value.startswith("@"):
|
|
375
|
+
return value
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
from dynaconf.utils.parse_conf import parse_conf_data
|
|
379
|
+
|
|
380
|
+
return parse_conf_data(value, tomlfy=True, box_settings=settings)
|
|
381
|
+
except Exception:
|
|
382
|
+
logger.debug("Failed to parse Dynaconf value: %s", value)
|
|
383
|
+
return value
|
|
384
|
+
|
|
385
|
+
def _template_context(self, context: Mapping[str, Any]) -> dict[str, Any]:
|
|
386
|
+
rv = dict(context)
|
|
387
|
+
rv.setdefault("context", context)
|
|
388
|
+
rv["settings"] = settings
|
|
389
|
+
rv["this"] = settings
|
|
390
|
+
return rv
|
|
391
|
+
|
|
392
|
+
def _default_fields_from_context(
|
|
393
|
+
self, context: Mapping[str, Any]
|
|
394
|
+
) -> dict[str, Any]:
|
|
395
|
+
field_names = set(getattr(self.test_cls, "model_fields", {}))
|
|
396
|
+
default_keys = field_names | self.default_context_keys
|
|
397
|
+
return {
|
|
398
|
+
key: value for key, value in context.items() if key in default_keys
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
def _is_regression_spec(self, data: Mapping[str, Any]) -> bool:
|
|
402
|
+
return self.tests_key in data or self.defaults_key in data
|
|
403
|
+
|
|
404
|
+
def _extract_yaml_context(self, raw: str) -> dict[str, Any]:
|
|
405
|
+
text = self._extract_yaml_context_text(raw)
|
|
406
|
+
if text is None:
|
|
407
|
+
return {}
|
|
408
|
+
data = self.parse_text(Path("context.yaml"), text)
|
|
409
|
+
context = data.get(self.context_key, {})
|
|
410
|
+
return dict(context) if isinstance(context, Mapping) else {}
|
|
411
|
+
|
|
412
|
+
def _extract_yaml_context_text(self, raw: str) -> str | None:
|
|
413
|
+
lines = raw.splitlines()
|
|
414
|
+
context_re = re.compile(r"^context\s*:(.*)$")
|
|
415
|
+
|
|
416
|
+
for index, line in enumerate(lines):
|
|
417
|
+
if not line.strip() or line.startswith((" ", "\t")):
|
|
418
|
+
continue
|
|
419
|
+
if line.lstrip().startswith("#"):
|
|
420
|
+
continue
|
|
421
|
+
|
|
422
|
+
match = context_re.match(line)
|
|
423
|
+
if match is None:
|
|
424
|
+
continue
|
|
425
|
+
|
|
426
|
+
block = [line]
|
|
427
|
+
if match.group(1).strip():
|
|
428
|
+
return "\n".join(block)
|
|
429
|
+
|
|
430
|
+
for child in lines[index + 1 :]:
|
|
431
|
+
if (
|
|
432
|
+
child.strip()
|
|
433
|
+
and not child.startswith((" ", "\t"))
|
|
434
|
+
and not child.lstrip().startswith("#")
|
|
435
|
+
):
|
|
436
|
+
break
|
|
437
|
+
block.append(child)
|
|
438
|
+
return "\n".join(block)
|
|
439
|
+
|
|
440
|
+
return None
|
|
441
|
+
|
|
442
|
+
def _extract_toml_context(self, raw: str) -> dict[str, Any]:
|
|
443
|
+
text = self._extract_toml_context_text(raw)
|
|
444
|
+
if text is None:
|
|
445
|
+
return {}
|
|
446
|
+
data = self.parse_text(Path("context.toml"), text)
|
|
447
|
+
context = data.get(self.context_key, {})
|
|
448
|
+
return dict(context) if isinstance(context, Mapping) else {}
|
|
449
|
+
|
|
450
|
+
def _extract_toml_context_text(self, raw: str) -> str | None:
|
|
451
|
+
lines = raw.splitlines()
|
|
452
|
+
context_line_re = re.compile(r"^context\s*=")
|
|
453
|
+
context_table_re = re.compile(r"^\[context(?:\.|\])")
|
|
454
|
+
table_re = re.compile(r"^\[")
|
|
455
|
+
|
|
456
|
+
for index, line in enumerate(lines):
|
|
457
|
+
stripped = line.strip()
|
|
458
|
+
if context_line_re.match(stripped):
|
|
459
|
+
return stripped
|
|
460
|
+
|
|
461
|
+
if context_table_re.match(stripped):
|
|
462
|
+
block = [line]
|
|
463
|
+
for child in lines[index + 1 :]:
|
|
464
|
+
child_stripped = child.strip()
|
|
465
|
+
if table_re.match(child_stripped) and not (
|
|
466
|
+
context_table_re.match(child_stripped)
|
|
467
|
+
):
|
|
468
|
+
break
|
|
469
|
+
block.append(child)
|
|
470
|
+
return "\n".join(block)
|
|
471
|
+
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
def _extract_json_context(self, raw: str) -> dict[str, Any]:
|
|
475
|
+
value = self._extract_json_context_value(raw)
|
|
476
|
+
if value is None:
|
|
477
|
+
return {}
|
|
478
|
+
data = json.loads(f'{{"context": {value}}}')
|
|
479
|
+
context = data.get(self.context_key, {})
|
|
480
|
+
return dict(context) if isinstance(context, Mapping) else {}
|
|
481
|
+
|
|
482
|
+
def _extract_json_context_value(self, raw: str) -> str | None:
|
|
483
|
+
decoder = json.JSONDecoder()
|
|
484
|
+
index = raw.find("{")
|
|
485
|
+
if index < 0:
|
|
486
|
+
return None
|
|
487
|
+
|
|
488
|
+
depth = 0
|
|
489
|
+
while index < len(raw):
|
|
490
|
+
char = raw[index]
|
|
491
|
+
if char == "{":
|
|
492
|
+
depth += 1
|
|
493
|
+
index += 1
|
|
494
|
+
continue
|
|
495
|
+
if char == "}":
|
|
496
|
+
depth -= 1
|
|
497
|
+
index += 1
|
|
498
|
+
continue
|
|
499
|
+
if depth == 1 and char == '"':
|
|
500
|
+
try:
|
|
501
|
+
key, end = decoder.raw_decode(raw, index)
|
|
502
|
+
except json.JSONDecodeError:
|
|
503
|
+
return None
|
|
504
|
+
index = end
|
|
505
|
+
if key != self.context_key:
|
|
506
|
+
continue
|
|
507
|
+
while index < len(raw) and raw[index].isspace():
|
|
508
|
+
index += 1
|
|
509
|
+
if index >= len(raw) or raw[index] != ":":
|
|
510
|
+
return None
|
|
511
|
+
index += 1
|
|
512
|
+
while index < len(raw) and raw[index].isspace():
|
|
513
|
+
index += 1
|
|
514
|
+
end = self._json_value_end(raw, index)
|
|
515
|
+
return raw[index:end]
|
|
516
|
+
index += 1
|
|
517
|
+
|
|
518
|
+
return None
|
|
519
|
+
|
|
520
|
+
def _json_value_end(self, raw: str, start: int) -> int:
|
|
521
|
+
in_string = False
|
|
522
|
+
escape = False
|
|
523
|
+
stack: list[str] = []
|
|
524
|
+
|
|
525
|
+
for index in range(start, len(raw)):
|
|
526
|
+
char = raw[index]
|
|
527
|
+
if in_string:
|
|
528
|
+
if escape:
|
|
529
|
+
escape = False
|
|
530
|
+
elif char == "\\":
|
|
531
|
+
escape = True
|
|
532
|
+
elif char == '"':
|
|
533
|
+
in_string = False
|
|
534
|
+
continue
|
|
535
|
+
|
|
536
|
+
if char == '"':
|
|
537
|
+
in_string = True
|
|
538
|
+
continue
|
|
539
|
+
if char in "{[":
|
|
540
|
+
stack.append("}" if char == "{" else "]")
|
|
541
|
+
continue
|
|
542
|
+
if stack and char == stack[-1]:
|
|
543
|
+
stack.pop()
|
|
544
|
+
if not stack:
|
|
545
|
+
return index + 1
|
|
546
|
+
continue
|
|
547
|
+
if not stack and char in ",}":
|
|
548
|
+
return index
|
|
549
|
+
|
|
550
|
+
return len(raw)
|
|
551
|
+
|
|
552
|
+
def _validate_file(self, path: str | Path) -> Path:
|
|
553
|
+
path = Path(path).expanduser()
|
|
554
|
+
if not path.is_file():
|
|
555
|
+
msg = f"Path does not point to a file: '{path}'"
|
|
556
|
+
raise ValueError(msg)
|
|
557
|
+
return path
|
|
558
|
+
|
|
559
|
+
def _resolve_symbol(self, value: Any, default: Any) -> Any:
|
|
560
|
+
if value is None or not value:
|
|
561
|
+
return default
|
|
562
|
+
if isinstance(value, str):
|
|
563
|
+
return _converter(value)
|
|
564
|
+
return value
|