rigyard 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. rigyard/__init__.py +19 -0
  2. rigyard/__main__.py +3 -0
  3. rigyard/application/__init__.py +30 -0
  4. rigyard/application/builds.py +60 -0
  5. rigyard/application/containers.py +63 -0
  6. rigyard/application/definitions.py +29 -0
  7. rigyard/application/images.py +61 -0
  8. rigyard/application/parameters.py +301 -0
  9. rigyard/application/project.py +77 -0
  10. rigyard/application/requests.py +70 -0
  11. rigyard/application/resolution.py +56 -0
  12. rigyard/application/scenarios.py +492 -0
  13. rigyard/application/tasks.py +57 -0
  14. rigyard/application/tests.py +58 -0
  15. rigyard/builds/__init__.py +5 -0
  16. rigyard/builds/backend.py +13 -0
  17. rigyard/builds/models.py +55 -0
  18. rigyard/builds/planner.py +25 -0
  19. rigyard/cli/__init__.py +5 -0
  20. rigyard/cli/branding.py +8 -0
  21. rigyard/cli/build_output.py +11 -0
  22. rigyard/cli/command_output.py +49 -0
  23. rigyard/cli/commands/__init__.py +1 -0
  24. rigyard/cli/commands/builds.py +53 -0
  25. rigyard/cli/commands/containers.py +74 -0
  26. rigyard/cli/commands/images.py +53 -0
  27. rigyard/cli/commands/parameters.py +91 -0
  28. rigyard/cli/commands/scenarios.py +181 -0
  29. rigyard/cli/commands/tasks.py +55 -0
  30. rigyard/cli/commands/tests.py +68 -0
  31. rigyard/cli/commands/workspace.py +124 -0
  32. rigyard/cli/common.py +89 -0
  33. rigyard/cli/container_output.py +73 -0
  34. rigyard/cli/main.py +117 -0
  35. rigyard/cli/menu/__init__.py +6 -0
  36. rigyard/cli/menu/app.py +477 -0
  37. rigyard/cli/menu/catalog.py +71 -0
  38. rigyard/cli/menu/model.py +24 -0
  39. rigyard/cli/menu/prompt.py +83 -0
  40. rigyard/cli/menu/session.py +22 -0
  41. rigyard/cli/scenario_output.py +84 -0
  42. rigyard/cli/style.py +128 -0
  43. rigyard/cli/task_output.py +11 -0
  44. rigyard/cli/test_output.py +14 -0
  45. rigyard/config/__init__.py +34 -0
  46. rigyard/config/catalog.py +89 -0
  47. rigyard/config/loader.py +303 -0
  48. rigyard/config/models.py +356 -0
  49. rigyard/container_commands/__init__.py +11 -0
  50. rigyard/container_commands/models.py +108 -0
  51. rigyard/container_commands/planner.py +37 -0
  52. rigyard/containers/__init__.py +1 -0
  53. rigyard/containers/backend.py +20 -0
  54. rigyard/containers/models.py +221 -0
  55. rigyard/containers/planner.py +212 -0
  56. rigyard/containers/service.py +17 -0
  57. rigyard/errors.py +148 -0
  58. rigyard/execution/__init__.py +15 -0
  59. rigyard/execution/docker_exec.py +175 -0
  60. rigyard/execution/runner.py +60 -0
  61. rigyard/execution/tty.py +5 -0
  62. rigyard/images/__init__.py +6 -0
  63. rigyard/images/backend.py +19 -0
  64. rigyard/images/models.py +181 -0
  65. rigyard/images/planner.py +159 -0
  66. rigyard/images/service.py +26 -0
  67. rigyard/immutable.py +40 -0
  68. rigyard/parameters/__init__.py +26 -0
  69. rigyard/parameters/context.py +50 -0
  70. rigyard/parameters/models.py +99 -0
  71. rigyard/parameters/prompt.py +134 -0
  72. rigyard/parameters/resolver.py +313 -0
  73. rigyard/parameters/sources.py +55 -0
  74. rigyard/parameters/templates.py +313 -0
  75. rigyard/providers/__init__.py +1 -0
  76. rigyard/providers/docker/__init__.py +23 -0
  77. rigyard/providers/docker/build_backend.py +28 -0
  78. rigyard/providers/docker/container_backend.py +206 -0
  79. rigyard/providers/docker/containers.py +54 -0
  80. rigyard/providers/docker/dockerfile.py +5 -0
  81. rigyard/providers/docker/exec_backend.py +116 -0
  82. rigyard/providers/docker/image_backend.py +70 -0
  83. rigyard/providers/docker/images.py +71 -0
  84. rigyard/providers/docker/sources.py +19 -0
  85. rigyard/providers/docker/task_backend.py +29 -0
  86. rigyard/providers/docker/test_backend.py +29 -0
  87. rigyard/scenarios/__init__.py +22 -0
  88. rigyard/scenarios/assembly.py +24 -0
  89. rigyard/scenarios/executor.py +347 -0
  90. rigyard/scenarios/identity.py +38 -0
  91. rigyard/scenarios/lifecycle.py +171 -0
  92. rigyard/scenarios/models.py +384 -0
  93. rigyard/scenarios/planner.py +258 -0
  94. rigyard/scenarios/ports.py +87 -0
  95. rigyard/scenarios/process.py +131 -0
  96. rigyard/scenarios/records.py +136 -0
  97. rigyard/scenarios/runtime.py +95 -0
  98. rigyard/scenarios/service.py +136 -0
  99. rigyard/scenarios/tmux.py +193 -0
  100. rigyard/tasks/__init__.py +5 -0
  101. rigyard/tasks/backend.py +13 -0
  102. rigyard/tasks/models.py +75 -0
  103. rigyard/tasks/planner.py +21 -0
  104. rigyard/tests/__init__.py +21 -0
  105. rigyard/tests/backend.py +13 -0
  106. rigyard/tests/models.py +74 -0
  107. rigyard/tests/planner.py +28 -0
  108. rigyard/version.py +3 -0
  109. rigyard/workspace.py +359 -0
  110. rigyard-1.0.0.dist-info/METADATA +188 -0
  111. rigyard-1.0.0.dist-info/RECORD +114 -0
  112. rigyard-1.0.0.dist-info/WHEEL +4 -0
  113. rigyard-1.0.0.dist-info/entry_points.txt +2 -0
  114. rigyard-1.0.0.dist-info/licenses/LICENSE +22 -0
rigyard/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """Configuration-driven tooling for containerized development workflows."""
2
+
3
+ from .config.loader import load_config, load_values
4
+ from .errors import RigyardError
5
+ from .parameters.context import ResolvedContext, ResolvedValue, ValueSource
6
+ from .parameters.resolver import RuntimeValueResolver, collect_prompts
7
+ from .version import __version__
8
+
9
+ __all__ = [
10
+ "RuntimeValueResolver",
11
+ "ResolvedContext",
12
+ "ResolvedValue",
13
+ "RigyardError",
14
+ "ValueSource",
15
+ "__version__",
16
+ "load_config",
17
+ "collect_prompts",
18
+ "load_values",
19
+ ]
rigyard/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli.main import main
2
+
3
+ main()
@@ -0,0 +1,30 @@
1
+ """Reusable application use cases shared by command and menu frontends."""
2
+
3
+ from .builds import BuildProjectUseCase
4
+ from .containers import CreateContainerUseCase
5
+ from .images import BuildImageUseCase
6
+ from .parameters import (
7
+ InspectParametersUseCase,
8
+ ResolveParametersUseCase,
9
+ ValidateConfigUseCase,
10
+ )
11
+ from .project import ProjectContext
12
+ from .requests import BuildImageRequest, ResolutionRequest
13
+ from .scenarios import PlanScenarioUseCase
14
+ from .tasks import ExecuteTaskUseCase
15
+ from .tests import ExecuteTestUseCase
16
+
17
+ __all__ = [
18
+ "BuildImageRequest",
19
+ "BuildImageUseCase",
20
+ "BuildProjectUseCase",
21
+ "CreateContainerUseCase",
22
+ "ExecuteTestUseCase",
23
+ "ExecuteTaskUseCase",
24
+ "InspectParametersUseCase",
25
+ "PlanScenarioUseCase",
26
+ "ProjectContext",
27
+ "ResolutionRequest",
28
+ "ResolveParametersUseCase",
29
+ "ValidateConfigUseCase",
30
+ ]
@@ -0,0 +1,60 @@
1
+ """Shared application use case for configured project builds."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from datetime import datetime
7
+
8
+ from ..builds.backend import BuildBackend
9
+ from ..builds.models import BuildPlan, BuildResult, BuildSpec
10
+ from ..builds.planner import BuildPlanner
11
+ from ..parameters.prompt import Formatter
12
+ from ..parameters.sources import DynamicSources
13
+ from .project import project_context
14
+ from .requests import ResolutionRequest
15
+ from .resolution import resolve_definition
16
+
17
+
18
+ class BuildProjectUseCase:
19
+ def __init__(
20
+ self,
21
+ backend: BuildBackend,
22
+ *,
23
+ sources: DynamicSources | None = None,
24
+ formatter: Formatter | None = None,
25
+ ) -> None:
26
+ self.backend = backend
27
+ self.sources = dict(sources or {})
28
+ self.formatter = formatter
29
+
30
+ def plan(
31
+ self,
32
+ build_name: str,
33
+ request: ResolutionRequest,
34
+ *,
35
+ environment: Mapping[str, str] | None = None,
36
+ now: datetime | None = None,
37
+ ) -> BuildPlan:
38
+ project = project_context(
39
+ request.config_path,
40
+ request.project,
41
+ environment=environment,
42
+ timestamp=now,
43
+ workspace_root=request.workspace_root,
44
+ )
45
+ spec = resolve_definition(
46
+ project,
47
+ request,
48
+ "builds",
49
+ build_name,
50
+ BuildSpec,
51
+ sources=self.sources,
52
+ formatter=self.formatter,
53
+ )
54
+ return BuildPlanner().create_plan(
55
+ build_name,
56
+ spec,
57
+ )
58
+
59
+ def execute(self, plan: BuildPlan) -> BuildResult:
60
+ return self.backend.execute(plan)
@@ -0,0 +1,63 @@
1
+ """Shared application use case for CLI and menu container creation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from datetime import datetime
7
+
8
+ from ..containers.backend import ContainerBackend
9
+ from ..containers.models import ContainerCreateResult, ContainerRunPlan, ContainerSpec
10
+ from ..containers.planner import ContainerRunPlanner
11
+ from ..containers.service import ContainerCreateService
12
+ from ..parameters.prompt import Formatter
13
+ from ..parameters.sources import DynamicSources
14
+ from .project import project_context
15
+ from .requests import ResolutionRequest
16
+ from .resolution import resolve_definition
17
+
18
+
19
+ class CreateContainerUseCase:
20
+ def __init__(
21
+ self,
22
+ backend: ContainerBackend,
23
+ *,
24
+ sources: DynamicSources | None = None,
25
+ formatter: Formatter | None = None,
26
+ ) -> None:
27
+ self.service = ContainerCreateService(backend)
28
+ self.sources = dict(sources or {})
29
+ self.formatter = formatter
30
+
31
+ def plan(
32
+ self,
33
+ container_name: str,
34
+ request: ResolutionRequest,
35
+ *,
36
+ environment: Mapping[str, str] | None = None,
37
+ now: datetime | None = None,
38
+ ) -> ContainerRunPlan:
39
+ project = project_context(
40
+ request.config_path,
41
+ request.project,
42
+ environment=environment,
43
+ timestamp=now,
44
+ workspace_root=request.workspace_root,
45
+ )
46
+ spec = resolve_definition(
47
+ project,
48
+ request,
49
+ "containers",
50
+ container_name,
51
+ ContainerSpec,
52
+ sources=self.sources,
53
+ formatter=self.formatter,
54
+ )
55
+ return ContainerRunPlanner().plan(
56
+ container_name,
57
+ spec,
58
+ project.config_path,
59
+ project.environment,
60
+ )
61
+
62
+ def execute(self, plan: ContainerRunPlan) -> ContainerCreateResult:
63
+ return self.service.create(plan)
@@ -0,0 +1,29 @@
1
+ """Application access to the canonical source-qualified definition catalog."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ..config.catalog import Definition, DefinitionCatalog
8
+ from ..config.models import RigyardConfig
9
+
10
+
11
+ def find_definition(
12
+ config: RigyardConfig,
13
+ kind: str,
14
+ name: str,
15
+ source_path: Path | None,
16
+ config_path: Path,
17
+ ) -> Definition | None:
18
+ return DefinitionCatalog(config, config_path).find(kind, name, source_path)
19
+
20
+
21
+ def definition_source_paths(
22
+ config: RigyardConfig,
23
+ config_path: Path,
24
+ source_path: Path | None = None,
25
+ ) -> dict[str, Path]:
26
+ return {
27
+ item.prefix: item.source_path
28
+ for item in DefinitionCatalog(config, config_path).selected(source_path)
29
+ }
@@ -0,0 +1,61 @@
1
+ """Application use case for building a configured image."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from datetime import datetime
7
+
8
+ from ..images.backend import ImageBuildBackend
9
+ from ..images.models import ImageBuildPlan, ImageBuildResult, ImageSpec
10
+ from ..images.planner import ImageBuildPlanner
11
+ from ..images.service import ImageBuildService
12
+ from ..parameters.prompt import Formatter
13
+ from ..parameters.sources import DynamicSources
14
+ from .project import project_context
15
+ from .requests import BuildImageRequest
16
+ from .resolution import resolve_definition
17
+
18
+
19
+ class BuildImageUseCase:
20
+ def __init__(
21
+ self,
22
+ backend: ImageBuildBackend,
23
+ *,
24
+ sources: DynamicSources | None = None,
25
+ formatter: Formatter | None = None,
26
+ ) -> None:
27
+ self.backend = backend
28
+ self.sources = dict(sources or {})
29
+ self.formatter = formatter
30
+
31
+ def plan(
32
+ self,
33
+ request: BuildImageRequest,
34
+ *,
35
+ environment: Mapping[str, str] | None = None,
36
+ now: datetime | None = None,
37
+ ) -> ImageBuildPlan:
38
+ project = project_context(
39
+ request.config_path,
40
+ request.project,
41
+ environment=environment,
42
+ timestamp=now,
43
+ workspace_root=request.workspace_root,
44
+ )
45
+ spec = resolve_definition(
46
+ project,
47
+ request.resolution_request(),
48
+ "images",
49
+ request.image_name,
50
+ ImageSpec,
51
+ sources=self.sources,
52
+ formatter=self.formatter,
53
+ )
54
+ return ImageBuildPlanner().create_plan(
55
+ request.image_name,
56
+ spec,
57
+ project.config_path,
58
+ )
59
+
60
+ def execute(self, plan: ImageBuildPlan) -> ImageBuildResult:
61
+ return ImageBuildService(self.backend).build(plan)
@@ -0,0 +1,301 @@
1
+ """Application boundary for inline runtime value discovery and resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from pathlib import Path
7
+ from typing import Any, TypeVar
8
+
9
+ from pydantic import BaseModel
10
+
11
+ from ..builds.models import BuildSpec
12
+ from ..config.catalog import DefinitionCatalog
13
+ from ..config.loader import load_config, load_values
14
+ from ..config.models import RigyardConfig
15
+ from ..containers.models import ContainerSpec
16
+ from ..errors import ResolutionError
17
+ from ..images.models import ImageSpec
18
+ from ..parameters.context import ResolvedContext
19
+ from ..parameters.prompt import Formatter
20
+ from ..parameters.resolver import (
21
+ RuntimeValueResolver,
22
+ collect_prompts,
23
+ flatten_values,
24
+ materialize_as,
25
+ )
26
+ from ..parameters.sources import DynamicSources
27
+ from ..parameters.templates import (
28
+ SourceAwareStringTemplateRenderer,
29
+ StringTemplateRenderer,
30
+ TemplateContext,
31
+ validate_template_syntax,
32
+ )
33
+ from ..scenarios.models import (
34
+ ScenarioComposeSpec,
35
+ ScenarioInstanceSpec,
36
+ ScenarioProfileSpec,
37
+ ScenarioStartupSpec,
38
+ ScenarioTemplate,
39
+ )
40
+ from ..tasks.models import TaskSpec
41
+ from ..tests.models import TestSpec
42
+ from .definitions import definition_source_paths
43
+ from .project import project_context
44
+ from .requests import ResolutionRequest
45
+
46
+ ModelT = TypeVar("ModelT", bound=BaseModel)
47
+
48
+
49
+ class ValidateConfigUseCase:
50
+ def execute(
51
+ self,
52
+ config_path: str | Path,
53
+ *,
54
+ workspace_root: str | Path | None = None,
55
+ ) -> RigyardConfig:
56
+ config = load_config(config_path, workspace_root=workspace_root)
57
+ _validate_config_templates(config, config_path)
58
+ return config
59
+
60
+
61
+ class InspectParametersUseCase:
62
+ def execute(
63
+ self,
64
+ config_path: str | Path,
65
+ source_path: Path | None = None,
66
+ *,
67
+ workspace_root: str | Path | None = None,
68
+ ) -> dict[str, Any]:
69
+ config = load_config(config_path, workspace_root=workspace_root)
70
+ _validate_config_templates(config, config_path)
71
+ prompts = _collect_config_prompts(config, Path(config_path), source_path)
72
+ return {
73
+ "version": config.version,
74
+ "runtime_values": RuntimeValueResolver(prompts).inspect(),
75
+ }
76
+
77
+
78
+ class ResolveParametersUseCase:
79
+ def __init__(
80
+ self,
81
+ sources: DynamicSources | None = None,
82
+ formatter: Formatter | None = None,
83
+ ) -> None:
84
+ self.sources = dict(sources or {})
85
+ self.formatter = formatter
86
+
87
+ def execute(
88
+ self, request: ResolutionRequest, *, allow_missing: bool = False
89
+ ) -> ResolvedContext:
90
+ project = project_context(
91
+ request.config_path,
92
+ request.project,
93
+ workspace_root=request.workspace_root,
94
+ )
95
+ config = project.config
96
+ template_context = TemplateContext.capture(
97
+ project.environment,
98
+ now=project.timestamp,
99
+ config_path=project.config_path,
100
+ workspace_root=project.workspace_root,
101
+ variables=config.variables,
102
+ )
103
+ renderer = SourceAwareStringTemplateRenderer(
104
+ template_context,
105
+ definition_source_paths(config, project.config_path, request.source_path),
106
+ )
107
+ context = resolve_prompts(
108
+ config,
109
+ request,
110
+ allow_missing=allow_missing,
111
+ renderer=renderer,
112
+ sources=self.sources,
113
+ formatter=self.formatter,
114
+ environment=project.environment,
115
+ )
116
+ if not allow_missing:
117
+ targets: dict[str, type[BaseModel]] = {
118
+ "images": ImageSpec,
119
+ "containers": ContainerSpec,
120
+ "builds": BuildSpec,
121
+ "tests": TestSpec,
122
+ "tasks": TaskSpec,
123
+ }
124
+ for entry in DefinitionCatalog(config, project.config_path).selected(
125
+ request.source_path
126
+ ):
127
+ if isinstance(entry.value, ScenarioTemplate):
128
+ scenario = entry.value
129
+ if scenario.compose is not None:
130
+ materialize_as(
131
+ scenario.compose,
132
+ f"{entry.prefix}.compose",
133
+ context,
134
+ ScenarioComposeSpec,
135
+ renderer,
136
+ )
137
+ materialize_as(
138
+ scenario.startup,
139
+ f"{entry.prefix}.startup",
140
+ context,
141
+ ScenarioStartupSpec,
142
+ renderer,
143
+ )
144
+ for name, instance in scenario.instances.items():
145
+ materialize_as(
146
+ instance,
147
+ f"{entry.prefix}.instances.{name}",
148
+ context,
149
+ ScenarioInstanceSpec,
150
+ renderer,
151
+ )
152
+ for name, profile in scenario.profiles.items():
153
+ materialize_as(
154
+ profile,
155
+ f"{entry.prefix}.profiles.{name}",
156
+ context,
157
+ ScenarioProfileSpec,
158
+ renderer,
159
+ )
160
+ else:
161
+ materialize_as(
162
+ entry.value, entry.prefix, context, targets[entry.kind], renderer
163
+ )
164
+ return context
165
+
166
+
167
+ def _validate_config_templates(config: RigyardConfig, config_path: str | Path) -> None:
168
+ base = TemplateContext.capture({}, config_path=config_path)
169
+ previous: list[str] = []
170
+ for name, value in config.variables.items():
171
+ StringTemplateRenderer(base, variable_names=previous).validate(value, f"variables.{name}")
172
+ previous.append(name)
173
+ renderer = StringTemplateRenderer(base, variable_names=config.variables)
174
+ for entry in DefinitionCatalog(config, Path(config_path)).entries:
175
+ validate_template_syntax(entry.value, entry.prefix, renderer=renderer)
176
+
177
+
178
+ def resolve_prompts(
179
+ template: BaseModel,
180
+ request: ResolutionRequest,
181
+ *,
182
+ prefix: str = "",
183
+ allow_missing: bool = False,
184
+ renderer: StringTemplateRenderer | None = None,
185
+ sources: DynamicSources | None = None,
186
+ formatter: Formatter | None = None,
187
+ environment: Mapping[str, str] | None = None,
188
+ ) -> ResolvedContext:
189
+ prompts = (
190
+ _collect_config_prompts(template, request.config_path, request.source_path)
191
+ if isinstance(template, RigyardConfig) and not prefix
192
+ else collect_prompts(template, prefix)
193
+ )
194
+ values = load_values(request.values_path) if request.values_path else {}
195
+ return RuntimeValueResolver(
196
+ prompts,
197
+ render_default=default_display_renderer(renderer),
198
+ sources=sources,
199
+ formatter=formatter,
200
+ ).resolve(
201
+ values=values,
202
+ environ=environment if environment is not None else _request_environment(request),
203
+ overrides=request.overrides,
204
+ interactive=request.interactive,
205
+ input_fn=request.input_fn,
206
+ allow_missing=allow_missing,
207
+ )
208
+
209
+
210
+ def _collect_config_prompts(
211
+ config: RigyardConfig,
212
+ config_path: Path,
213
+ source_path: Path | None = None,
214
+ ) -> dict[str, Any]:
215
+ prompts: dict[str, Any] = {}
216
+ for entry in DefinitionCatalog(config, config_path).selected(source_path):
217
+ prompts.update(collect_prompts(entry.value, entry.prefix))
218
+ return prompts
219
+
220
+
221
+ def _available_prompts(root: BaseModel, request: ResolutionRequest) -> dict[str, Any]:
222
+ if not isinstance(root, RigyardConfig):
223
+ return collect_prompts(root)
224
+ prompts: dict[str, Any] = {}
225
+ for entry in DefinitionCatalog(root, request.config_path).entries:
226
+ prompts.update(collect_prompts(entry.value, entry.prefix))
227
+ return prompts
228
+
229
+
230
+ def resolve_template(
231
+ root: BaseModel,
232
+ template: BaseModel,
233
+ request: ResolutionRequest,
234
+ prefix: str,
235
+ target: type[ModelT],
236
+ renderer: StringTemplateRenderer | None = None,
237
+ sources: DynamicSources | None = None,
238
+ formatter: Formatter | None = None,
239
+ environment: Mapping[str, str] | None = None,
240
+ ) -> tuple[ModelT, ResolvedContext]:
241
+ selected = collect_prompts(template, prefix)
242
+ context = resolve_selected_prompts(
243
+ root,
244
+ selected,
245
+ request,
246
+ renderer=renderer,
247
+ sources=sources,
248
+ formatter=formatter,
249
+ environment=environment,
250
+ )
251
+ return materialize_as(template, prefix, context, target, renderer), context
252
+
253
+
254
+ def resolve_selected_prompts(
255
+ root: BaseModel,
256
+ selected: dict[str, Any],
257
+ request: ResolutionRequest,
258
+ *,
259
+ renderer: StringTemplateRenderer | None = None,
260
+ sources: DynamicSources | None = None,
261
+ formatter: Formatter | None = None,
262
+ environment: Mapping[str, str] | None = None,
263
+ ) -> ResolvedContext:
264
+ """Resolve an explicitly composed set of prompt paths from one config root."""
265
+
266
+ available = _available_prompts(root, request)
267
+ available.update(selected)
268
+ values = load_values(request.values_path) if request.values_path else {}
269
+ flat_values = flatten_values(values)
270
+ unknown = (set(flat_values) | set(request.overrides)) - set(available)
271
+ if unknown:
272
+ raise ResolutionError(f"unknown runtime value(s): {', '.join(sorted(unknown))}")
273
+ return RuntimeValueResolver(
274
+ selected,
275
+ render_default=default_display_renderer(renderer),
276
+ sources=sources,
277
+ formatter=formatter,
278
+ ).resolve(
279
+ values={key: value for key, value in flat_values.items() if key in selected},
280
+ environ=environment if environment is not None else _request_environment(request),
281
+ overrides={key: value for key, value in request.overrides.items() if key in selected},
282
+ interactive=request.interactive,
283
+ input_fn=request.input_fn,
284
+ )
285
+
286
+
287
+ def _request_environment(request: ResolutionRequest) -> Mapping[str, str] | None:
288
+ return request.project.environment if request.project is not None else None
289
+
290
+
291
+ def default_display_renderer(
292
+ renderer: StringTemplateRenderer | None,
293
+ ) -> Callable[[str, Any], str] | None:
294
+ """Adapt a template renderer for rendering prompt defaults in hints."""
295
+ if renderer is None:
296
+ return None
297
+
298
+ def render(path: str, value: Any) -> str:
299
+ return str(renderer.render_value(value, path))
300
+
301
+ return render
@@ -0,0 +1,77 @@
1
+ """A stable project snapshot shared across one frontend operation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from types import MappingProxyType
11
+
12
+ from ..config.loader import load_config
13
+ from ..config.models import RigyardConfig
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ProjectContext:
18
+ """Configuration and host context captured together at one point in time."""
19
+
20
+ config_path: Path
21
+ config: RigyardConfig
22
+ environment: Mapping[str, str]
23
+ timestamp: datetime
24
+ workspace_root: Path = field(default_factory=lambda: Path.cwd().resolve())
25
+
26
+ def __post_init__(self) -> None:
27
+ object.__setattr__(self, "config_path", Path(self.config_path))
28
+ object.__setattr__(self, "workspace_root", Path(self.workspace_root).resolve())
29
+ object.__setattr__(self, "environment", MappingProxyType(dict(self.environment)))
30
+
31
+ @classmethod
32
+ def capture(
33
+ cls,
34
+ config_path: str | Path,
35
+ *,
36
+ config: RigyardConfig | None = None,
37
+ environment: Mapping[str, str] | None = None,
38
+ timestamp: datetime | None = None,
39
+ workspace_root: str | Path | None = None,
40
+ ) -> ProjectContext:
41
+ path = Path(config_path)
42
+ root = Path.cwd().resolve() if workspace_root is None else Path(workspace_root).resolve()
43
+ return cls(
44
+ config_path=path,
45
+ config=load_config(path, workspace_root=root) if config is None else config,
46
+ environment=os.environ if environment is None else environment,
47
+ timestamp=datetime.now().astimezone() if timestamp is None else timestamp,
48
+ workspace_root=root,
49
+ )
50
+
51
+
52
+ def project_context(
53
+ config_path: str | Path,
54
+ current: ProjectContext | None,
55
+ *,
56
+ environment: Mapping[str, str] | None = None,
57
+ timestamp: datetime | None = None,
58
+ workspace_root: str | Path | None = None,
59
+ ) -> ProjectContext:
60
+ """Reuse a supplied snapshot while honoring explicit test/runtime overrides."""
61
+
62
+ if current is None:
63
+ return ProjectContext.capture(
64
+ config_path,
65
+ environment=environment,
66
+ timestamp=timestamp,
67
+ workspace_root=workspace_root,
68
+ )
69
+ if environment is None and timestamp is None and workspace_root is None:
70
+ return current
71
+ return ProjectContext(
72
+ config_path=current.config_path,
73
+ config=current.config,
74
+ environment=current.environment if environment is None else environment,
75
+ timestamp=current.timestamp if timestamp is None else timestamp,
76
+ workspace_root=current.workspace_root if workspace_root is None else Path(workspace_root),
77
+ )