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.
Files changed (117) hide show
  1. {socx_cli-0.13.11 → socx_cli-0.13.13}/PKG-INFO +2 -2
  2. {socx_cli-0.13.11 → socx_cli-0.13.13}/pyproject.toml +2 -2
  3. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/__init__.py +4 -0
  4. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/__init__.py +4 -0
  5. socx_cli-0.13.13/socx/core/loader.py +19 -0
  6. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/schema/types.py +4 -1
  7. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/regression/__init__.py +2 -0
  8. socx_cli-0.13.13/socx/regression/loader.py +564 -0
  9. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/regression/progress.py +27 -15
  10. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/regression/regression.py +37 -49
  11. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/regression/test.py +36 -45
  12. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/static/settings/git.yaml +0 -2
  13. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/static/settings/regression.yaml +7 -2
  14. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/regression/_run.py +4 -4
  15. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/regression/callbacks.py +1 -1
  16. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/widget.py +2 -4
  17. {socx_cli-0.13.11 → socx_cli-0.13.13}/.gitignore +0 -0
  18. {socx_cli-0.13.11 → socx_cli-0.13.13}/LICENSE +0 -0
  19. {socx_cli-0.13.11 → socx_cli-0.13.13}/README.md +0 -0
  20. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/__main__.py +0 -0
  21. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/cli/__init__.py +0 -0
  22. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/cli/_cli.py +0 -0
  23. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/cli/_jinja.py +0 -0
  24. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/cli/callbacks.py +0 -0
  25. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/cli/cfg.py +0 -0
  26. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/cli/cli.py +0 -0
  27. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/cli/params.py +0 -0
  28. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/cli/types.py +0 -0
  29. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/config/__init__.py +0 -0
  30. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/config/_config.py +0 -0
  31. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/config/_settings.py +0 -0
  32. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/config/converters.py +0 -0
  33. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/config/encoders.py +0 -0
  34. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/config/formatters.py +0 -0
  35. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/config/serializers.py +0 -0
  36. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/config/validators.py +0 -0
  37. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/_paths.py +0 -0
  38. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/encoder.py +0 -0
  39. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/enums.py +0 -0
  40. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/funcs.py +0 -0
  41. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/metadata.py +0 -0
  42. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/paths.py +0 -0
  43. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/schema/__init__.py +0 -0
  44. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/schema/git/__init__.py +0 -0
  45. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/schema/git/git.py +0 -0
  46. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/schema/git/manifest.py +0 -0
  47. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/schema/plugin.py +0 -0
  48. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/core/serializer.py +0 -0
  49. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/git/__init__.py +0 -0
  50. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/git/_git.py +0 -0
  51. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/git/_manifest.py +0 -0
  52. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/git/_ssh.py +0 -0
  53. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/io/__init__.py +0 -0
  54. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/io/console.py +0 -0
  55. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/io/decorators.py +0 -0
  56. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/io/log.py +0 -0
  57. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/patterns/__init__.py +0 -0
  58. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/patterns/mixins/__init__.py +0 -0
  59. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/patterns/mixins/proxy.py +0 -0
  60. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/patterns/mixins/uid.py +0 -0
  61. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/patterns/singleton/__init__.py +0 -0
  62. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/patterns/singleton/singleton.py +0 -0
  63. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/patterns/visitor/__init__.py +0 -0
  64. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/patterns/visitor/protocol.py +0 -0
  65. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/patterns/visitor/traversal.py +0 -0
  66. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/regression/status.py +0 -0
  67. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/regression/validator.py +0 -0
  68. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/regression/visitor.py +0 -0
  69. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/static/settings/cli.yaml +0 -0
  70. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/static/settings/console.yaml +0 -0
  71. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/static/settings/logging.yaml +0 -0
  72. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/static/settings/plugins.yaml +0 -0
  73. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/static/settings/rich_click.yaml +0 -0
  74. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/static/settings/settings.yaml +0 -0
  75. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/static/sql/socx.sql +0 -0
  76. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/utils/__init__.py +0 -0
  77. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx/utils/decorators.py +0 -0
  78. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/config/__init__.py +0 -0
  79. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/config/_config.py +0 -0
  80. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/config/edit.py +0 -0
  81. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/git/__init__.py +0 -0
  82. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/git/arguments.py +0 -0
  83. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/git/callbacks.py +0 -0
  84. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/git/cli.py +0 -0
  85. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/git/manifest.py +0 -0
  86. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/git/renderables.py +0 -0
  87. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/git/summary.py +0 -0
  88. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/git/utils.py +0 -0
  89. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/plugin/__init__.py +0 -0
  90. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/plugin/example.py +0 -0
  91. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/plugin/schema.py +0 -0
  92. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/regression/__init__.py +0 -0
  93. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/regression/cli.py +0 -0
  94. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/regression/run.py +0 -0
  95. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/regression/serve.py +0 -0
  96. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/regression/tui.py +0 -0
  97. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/version/__init__.py +0 -0
  98. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_plugins/version/__main__.py +0 -0
  99. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/__init__.py +0 -0
  100. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/__init__.py +0 -0
  101. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/__main__.py +0 -0
  102. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/app.py +0 -0
  103. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/bindings/__init__.py +0 -0
  104. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/bindings/vim/__init__.py +0 -0
  105. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/bindings/vim/mode.py +0 -0
  106. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/bindings/vim/vim.py +0 -0
  107. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/containers.py +0 -0
  108. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/details.py +0 -0
  109. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/dialog.py +0 -0
  110. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/mixins/__init__.py +0 -0
  111. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/mixins/composable.py +0 -0
  112. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/mixins/configurable.py +0 -0
  113. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/preview.py +0 -0
  114. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/table.py +0 -0
  115. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/regression/tree.py +0 -0
  116. {socx_cli-0.13.11 → socx_cli-0.13.13}/socx_tui/static/tcss/regression/app.tcss +0 -0
  117. {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.11
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.11"
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(list(value))
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