osprey-optimizer 2026.11.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.
- osprey_optimizer/__init__.py +16 -0
- osprey_optimizer/_version.py +24 -0
- osprey_optimizer/api/__init__.py +12 -0
- osprey_optimizer/api/dependencies.py +58 -0
- osprey_optimizer/api/environment_ops.py +294 -0
- osprey_optimizer/api/job_control.py +416 -0
- osprey_optimizer/api/job_status.py +71 -0
- osprey_optimizer/api/job_tracker.py +524 -0
- osprey_optimizer/api/main.py +289 -0
- osprey_optimizer/api/manual_authorization.py +210 -0
- osprey_optimizer/api/optimization_lifecycle.py +329 -0
- osprey_optimizer/api/optimization_status.py +157 -0
- osprey_optimizer/api/owner.py +96 -0
- osprey_optimizer/api/routers/__init__.py +0 -0
- osprey_optimizer/api/routers/environments.py +218 -0
- osprey_optimizer/api/routers/jobs.py +306 -0
- osprey_optimizer/api/routers/models.py +64 -0
- osprey_optimizer/api/routers/optimization.py +39 -0
- osprey_optimizer/api/routers/results.py +245 -0
- osprey_optimizer/api/routers/run_config.py +142 -0
- osprey_optimizer/api/runner.py +58 -0
- osprey_optimizer/api/security.py +53 -0
- osprey_optimizer/api/start_gates.py +61 -0
- osprey_optimizer/api/summary.py +286 -0
- osprey_optimizer/config/__init__.py +0 -0
- osprey_optimizer/config/logging.py +38 -0
- osprey_optimizer/environments/__init__.py +1 -0
- osprey_optimizer/environments/connector_env.py +413 -0
- osprey_optimizer/environments/interface.py +404 -0
- osprey_optimizer/environments/spec.py +231 -0
- osprey_optimizer/environments/write_authority.py +108 -0
- osprey_optimizer/events.py +177 -0
- osprey_optimizer/models/__init__.py +5 -0
- osprey_optimizer/models/api.py +261 -0
- osprey_optimizer/models/config.py +254 -0
- osprey_optimizer/models/enums.py +98 -0
- osprey_optimizer/models/exceptions.py +149 -0
- osprey_optimizer/models/process_scope.py +23 -0
- osprey_optimizer/models/protocols.py +417 -0
- osprey_optimizer/models/state.py +44 -0
- osprey_optimizer/models/testing.py +28 -0
- osprey_optimizer/models/types.py +75 -0
- osprey_optimizer/optimization/__init__.py +1 -0
- osprey_optimizer/optimization/best_point.py +113 -0
- osprey_optimizer/optimization/coercion.py +45 -0
- osprey_optimizer/optimization/evaluation.py +173 -0
- osprey_optimizer/optimization/generators/__init__.py +11 -0
- osprey_optimizer/optimization/generators/baxus.py +631 -0
- osprey_optimizer/optimization/runner.py +354 -0
- osprey_optimizer/optimization/state.py +401 -0
- osprey_optimizer/optimization/surrogate/__init__.py +5 -0
- osprey_optimizer/optimization/surrogate/constructor.py +235 -0
- osprey_optimizer/optimization/surrogate/wrapper.py +162 -0
- osprey_optimizer/optimization/types.py +29 -0
- osprey_optimizer/optimization/xopt_backend.py +591 -0
- osprey_optimizer/results.py +601 -0
- osprey_optimizer/services/__init__.py +0 -0
- osprey_optimizer/services/bounds_resolver.py +167 -0
- osprey_optimizer/services/draft_resolution.py +108 -0
- osprey_optimizer/services/execution.py +567 -0
- osprey_optimizer/services/orchestration.py +286 -0
- osprey_optimizer/services/plugin_registry.py +221 -0
- osprey_optimizer/services/result_pipeline.py +75 -0
- osprey_optimizer/services/write_authorization.py +500 -0
- osprey_optimizer/utils/__init__.py +0 -0
- osprey_optimizer/utils/cancellation.py +220 -0
- osprey_optimizer/utils/control.py +35 -0
- osprey_optimizer/utils/interval_thread.py +78 -0
- osprey_optimizer/utils/job_keys.py +57 -0
- osprey_optimizer/utils/json_safe.py +35 -0
- osprey_optimizer/utils/log_capture.py +64 -0
- osprey_optimizer/utils/numeric.py +14 -0
- osprey_optimizer/utils/process.py +88 -0
- osprey_optimizer/utils/redis_client.py +95 -0
- osprey_optimizer/utils/redis_state_store.py +149 -0
- osprey_optimizer/utils/serialization.py +24 -0
- osprey_optimizer-2026.11.0.dist-info/METADATA +52 -0
- osprey_optimizer-2026.11.0.dist-info/RECORD +80 -0
- osprey_optimizer-2026.11.0.dist-info/WHEEL +4 -0
- osprey_optimizer-2026.11.0.dist-info/licenses/LICENSE +28 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Optimizer service: Xopt-driven tuning jobs behind a FastAPI job API.
|
|
2
|
+
|
|
3
|
+
Ported from tuning_scripts at commit 1bac27a1.
|
|
4
|
+
|
|
5
|
+
The base install carries the contracts: ``models`` and ``environments`` load
|
|
6
|
+
with pydantic, PyYAML and osprey-connectors only, and the OSPREY framework
|
|
7
|
+
imports them. The search stack (xopt, torch, botorch, polars, redis, fastapi)
|
|
8
|
+
is the ``service`` extra, installed only in the optimizer's image.
|
|
9
|
+
|
|
10
|
+
Sub-packages:
|
|
11
|
+
- models: request/response models, environment protocols, enums
|
|
12
|
+
- environments: environment files and the connector-driven environment
|
|
13
|
+
- api: FastAPI app, routers, job tracking and control (needs ``service``)
|
|
14
|
+
- optimization: phase runner, evaluator, Xopt backend, custom generators
|
|
15
|
+
- services: execution and orchestration inside the job worker
|
|
16
|
+
"""
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '2026.11.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (2026, 11, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""The optimizer's HTTP service. Needs the ``service`` extra."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
|
|
5
|
+
_SERVICE_ROOTS = ("fastapi", "redis", "polars", "xopt", "torch")
|
|
6
|
+
|
|
7
|
+
_missing = [name for name in _SERVICE_ROOTS if importlib.util.find_spec(name) is None]
|
|
8
|
+
if _missing:
|
|
9
|
+
raise ImportError(
|
|
10
|
+
f"osprey_optimizer.api needs the optimizer's service extra (missing: {', '.join(_missing)}). "
|
|
11
|
+
'Install it with: pip install "osprey-optimizer[service]"'
|
|
12
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""FastAPI dependency callables for shared singletons.
|
|
2
|
+
|
|
3
|
+
Each function reads from ``request.app.state``, which is populated by the
|
|
4
|
+
lifespan handler in ``main.py``. Use them with ``Depends()`` in endpoint
|
|
5
|
+
signatures so that routers stay decoupled from initialization logic.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from fastapi import Depends, Query, Request
|
|
12
|
+
|
|
13
|
+
from osprey_optimizer.api.job_status import load_job_meta
|
|
14
|
+
from osprey_optimizer.api.job_tracker import JobMeta, JobTracker
|
|
15
|
+
from osprey_optimizer.models.config import ControlTarget, ExecutionMode, machine_config
|
|
16
|
+
from osprey_optimizer.services.plugin_registry import EnvironmentRegistry
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_job_tracker(request: Request) -> JobTracker:
|
|
20
|
+
return request.app.state.job_tracker # type: ignore[no-any-return]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def require_job_meta(job_id: str, tracker: JobTracker = Depends(get_job_tracker)) -> JobMeta:
|
|
24
|
+
"""Resolve the path job's metadata once (404 if unknown, 500 if corrupt).
|
|
25
|
+
|
|
26
|
+
Endpoints under ``/jobs/{job_id}`` inject this instead of re-checking
|
|
27
|
+
existence and re-reading the record: FastAPI caches it for the request, so
|
|
28
|
+
the type/creation-time checks they used to make one call each now share a
|
|
29
|
+
single Redis read.
|
|
30
|
+
"""
|
|
31
|
+
return load_job_meta(tracker, job_id)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_environment_registry(request: Request) -> EnvironmentRegistry:
|
|
35
|
+
return request.app.state.environment_registry # type: ignore[no-any-return]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Machine:
|
|
40
|
+
"""The machine a request reaches, as the request named it."""
|
|
41
|
+
|
|
42
|
+
target: ControlTarget
|
|
43
|
+
execution_mode: ExecutionMode | None = None
|
|
44
|
+
|
|
45
|
+
def env_config(self) -> dict[str, Any]:
|
|
46
|
+
return machine_config(self.target, self.execution_mode)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def require_machine(
|
|
50
|
+
target: ControlTarget = Query(
|
|
51
|
+
description="The machine this call reaches: va, standin or live. No default."
|
|
52
|
+
),
|
|
53
|
+
execution_mode: ExecutionMode | None = Query(
|
|
54
|
+
default=None, description="readonly: reads only; every write is refused."
|
|
55
|
+
),
|
|
56
|
+
) -> Machine:
|
|
57
|
+
"""Every endpoint that reaches a machine names it; there is no ambient target."""
|
|
58
|
+
return Machine(target=target, execution_mode=execution_mode)
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Shared helpers for loading environments and reading/writing variables.
|
|
2
|
+
|
|
3
|
+
Extracted from ``routers/environments.py`` so multiple routers (environments,
|
|
4
|
+
run-config, results/restore) share one machine-access path and error mapping.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from fastapi import HTTPException, status
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from osprey_optimizer.api.job_tracker import JobTracker
|
|
16
|
+
|
|
17
|
+
from osprey_optimizer.models.api import EnvironmentDetails, EnvironmentStatusResponse
|
|
18
|
+
from osprey_optimizer.models.config import (
|
|
19
|
+
ControlTarget,
|
|
20
|
+
ExecutionMode,
|
|
21
|
+
Preset,
|
|
22
|
+
machine_config,
|
|
23
|
+
)
|
|
24
|
+
from osprey_optimizer.models.exceptions import ConnectionError as OptimizerConnectionError
|
|
25
|
+
from osprey_optimizer.models.exceptions import TimeoutError as OptimizerTimeoutError
|
|
26
|
+
from osprey_optimizer.models.exceptions import VariableOutOfBoundsError
|
|
27
|
+
from osprey_optimizer.models.protocols import EnvironmentProtocol
|
|
28
|
+
from osprey_optimizer.services.plugin_registry import EnvironmentRegistry
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def require_known_environment(name: str, registry: EnvironmentRegistry) -> None:
|
|
34
|
+
"""422 unless ``name`` is a discoverable environment."""
|
|
35
|
+
known = {e.name for e in registry.list_environments()}
|
|
36
|
+
if name not in known:
|
|
37
|
+
raise HTTPException(
|
|
38
|
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
39
|
+
detail=f"Unknown environment '{name}'.",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def environment_details(name: str, registry: EnvironmentRegistry) -> EnvironmentDetails:
|
|
44
|
+
"""Assemble an environment's metadata — the single details builder.
|
|
45
|
+
|
|
46
|
+
Shared by the environments router and the current-environment reads so the
|
|
47
|
+
field set can't drift between endpoints.
|
|
48
|
+
"""
|
|
49
|
+
env = load_environment(name, registry)
|
|
50
|
+
with env:
|
|
51
|
+
return EnvironmentDetails.from_environment(name, env)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def probe_environment_health(
|
|
55
|
+
name: str, registry: EnvironmentRegistry, target: ControlTarget
|
|
56
|
+
) -> EnvironmentStatusResponse:
|
|
57
|
+
"""Probe an environment's connectivity on one target — the single health probe.
|
|
58
|
+
|
|
59
|
+
404 for an unknown name; any other probe failure degrades to
|
|
60
|
+
``connected=None`` with the error message rather than a 5xx.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
env = lookup_environment(name, registry, machine_config(target))
|
|
64
|
+
with env:
|
|
65
|
+
connected = env.check_connection()
|
|
66
|
+
return EnvironmentStatusResponse(name=name, connected=connected, message=None)
|
|
67
|
+
except HTTPException:
|
|
68
|
+
raise
|
|
69
|
+
except Exception as e:
|
|
70
|
+
logger.warning("Connectivity check failed for '%s': %s", name, e)
|
|
71
|
+
return EnvironmentStatusResponse(name=name, connected=None, message=str(e))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def is_sim_target(target: ControlTarget) -> bool:
|
|
75
|
+
"""True for the virtual accelerator, which no real-hardware safety gate applies to.
|
|
76
|
+
|
|
77
|
+
It gates restore-token safety and the health probe. The stand-in is not a
|
|
78
|
+
simulator here: it is where an operator rehearses real-machine behaviour.
|
|
79
|
+
"""
|
|
80
|
+
return target == "va"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _environment_connected(name: str, registry: EnvironmentRegistry, target: ControlTarget) -> bool:
|
|
84
|
+
return probe_environment_health(name, registry, target).connected is True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _assert_healthy(
|
|
88
|
+
name: str, registry: EnvironmentRegistry, target: ControlTarget, override_unhealthy: bool
|
|
89
|
+
) -> None:
|
|
90
|
+
if is_sim_target(target) or override_unhealthy:
|
|
91
|
+
return
|
|
92
|
+
if not _environment_connected(name, registry, target):
|
|
93
|
+
raise HTTPException(
|
|
94
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
95
|
+
detail=f"Environment '{name}' is not reachable on '{target}'; pass override_unhealthy to proceed.",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def guard_environment_write(
|
|
100
|
+
name: str,
|
|
101
|
+
target: ControlTarget,
|
|
102
|
+
tracker: "JobTracker",
|
|
103
|
+
registry: EnvironmentRegistry,
|
|
104
|
+
*,
|
|
105
|
+
override_unhealthy: bool,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Point-in-time precondition for a manual hardware write.
|
|
108
|
+
|
|
109
|
+
Requires no active job, and the environment healthy on ``target`` (or the
|
|
110
|
+
override). NOT a lock held across the write — a job may claim the slot after
|
|
111
|
+
this returns; that residual race is the accepted best-effort behavior. Whether
|
|
112
|
+
``target`` may be written at all is the connector's decision, on every put.
|
|
113
|
+
"""
|
|
114
|
+
if tracker.get_active_job() is not None:
|
|
115
|
+
raise HTTPException(
|
|
116
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
117
|
+
detail="A job is active; manual writes are locked.",
|
|
118
|
+
)
|
|
119
|
+
_assert_healthy(name, registry, target, override_unhealthy)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def guard_environment_start(
|
|
123
|
+
name: str, target: ControlTarget, registry: EnvironmentRegistry, *, override_unhealthy: bool
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Point-in-time precondition for a job start: the environment healthy on its target.
|
|
126
|
+
|
|
127
|
+
The active-slot claim (claim_active_job) enforces the single-running-job
|
|
128
|
+
rule, so this does not check the active-job lock.
|
|
129
|
+
"""
|
|
130
|
+
_assert_healthy(name, registry, target, override_unhealthy)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def machine_env_config(
|
|
134
|
+
target: ControlTarget, execution_mode: ExecutionMode | None
|
|
135
|
+
) -> dict[str, Any]:
|
|
136
|
+
"""Environment config for a call that reaches ``target``."""
|
|
137
|
+
return machine_config(target, execution_mode)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def lookup_environment(
|
|
141
|
+
name: str, registry: EnvironmentRegistry, env_config: dict[str, Any] | None = None
|
|
142
|
+
) -> EnvironmentProtocol:
|
|
143
|
+
"""Resolve an environment by name, mapping only the unknown-name case to 404.
|
|
144
|
+
|
|
145
|
+
Use this when the caller wants to handle other load failures itself (e.g.
|
|
146
|
+
``/status`` degrades them to ``connected=False`` rather than a 500).
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
return registry.load_environment(name, env_config or {})
|
|
150
|
+
except KeyError as e:
|
|
151
|
+
raise HTTPException(
|
|
152
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
153
|
+
detail=f"Environment '{name}' not found",
|
|
154
|
+
) from e
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def load_environment(
|
|
158
|
+
name: str, registry: EnvironmentRegistry, env_config: dict[str, Any] | None = None
|
|
159
|
+
) -> EnvironmentProtocol:
|
|
160
|
+
"""Load an environment by name, mapping failures to HTTP errors."""
|
|
161
|
+
try:
|
|
162
|
+
return lookup_environment(name, registry, env_config)
|
|
163
|
+
except HTTPException:
|
|
164
|
+
raise
|
|
165
|
+
except Exception as e:
|
|
166
|
+
logger.exception("Failed to load environment '%s'", name)
|
|
167
|
+
raise HTTPException(
|
|
168
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
169
|
+
detail=f"Failed to load environment '{name}': {e}",
|
|
170
|
+
) from e
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@contextmanager
|
|
174
|
+
def read_errors(name: str, action: str) -> Iterator[None]:
|
|
175
|
+
"""Map control-system read failures to HTTP errors.
|
|
176
|
+
|
|
177
|
+
``action`` names the read (e.g. ``"get variables"``) and is interpolated
|
|
178
|
+
into the 500 detail. The 503 path is the catastrophic case where the CA
|
|
179
|
+
worker thread never answered (wedged or never started).
|
|
180
|
+
|
|
181
|
+
Wrap only the control-system call; keep name validation before the block
|
|
182
|
+
and value transforms after it so their errors are not misclassified.
|
|
183
|
+
"""
|
|
184
|
+
try:
|
|
185
|
+
yield
|
|
186
|
+
except OptimizerTimeoutError as e:
|
|
187
|
+
logger.exception("CA worker unresponsive while reading from '%s'", name)
|
|
188
|
+
raise HTTPException(
|
|
189
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
190
|
+
detail=f"Control system worker unresponsive: {e}",
|
|
191
|
+
) from e
|
|
192
|
+
except Exception as e:
|
|
193
|
+
logger.exception("Failed to %s from '%s'", action, name)
|
|
194
|
+
raise HTTPException(
|
|
195
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
196
|
+
detail=f"Failed to {action}: {e}",
|
|
197
|
+
) from e
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@contextmanager
|
|
201
|
+
def write_errors(name: str, action: str) -> Iterator[None]:
|
|
202
|
+
"""Map control-system write failures to HTTP errors.
|
|
203
|
+
|
|
204
|
+
``action`` names the write (e.g. ``"set variables"``) and is interpolated
|
|
205
|
+
into the 500 detail. An out-of-bounds/unknown/non-finite value is a client
|
|
206
|
+
error (422); an unreachable or unresponsive control system is a 503.
|
|
207
|
+
|
|
208
|
+
Wrap only the control-system call; keep name validation before the block
|
|
209
|
+
and value transforms after it so their errors are not misclassified.
|
|
210
|
+
"""
|
|
211
|
+
try:
|
|
212
|
+
yield
|
|
213
|
+
except VariableOutOfBoundsError as e:
|
|
214
|
+
# Unknown variable, out-of-bounds, or non-finite value: client error.
|
|
215
|
+
raise HTTPException(
|
|
216
|
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
217
|
+
detail=str(e),
|
|
218
|
+
) from e
|
|
219
|
+
except (OptimizerConnectionError, OptimizerTimeoutError) as e:
|
|
220
|
+
logger.exception("Control system unreachable while trying to %s on '%s'", action, name)
|
|
221
|
+
raise HTTPException(
|
|
222
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
223
|
+
detail=f"Control system unavailable: {e}",
|
|
224
|
+
) from e
|
|
225
|
+
except Exception as e:
|
|
226
|
+
logger.exception("Failed to %s on '%s'", action, name)
|
|
227
|
+
raise HTTPException(
|
|
228
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
229
|
+
detail=f"Failed to {action}: {e}",
|
|
230
|
+
) from e
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def validate_observable_names(names: list[str], env: EnvironmentProtocol) -> None:
|
|
234
|
+
"""Raise 422 if any observable name is not in the environment metadata."""
|
|
235
|
+
unknown = [n for n in names if n not in env.observables]
|
|
236
|
+
if unknown:
|
|
237
|
+
raise HTTPException(
|
|
238
|
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
239
|
+
detail=f"Unknown observable(s): {unknown}",
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def lookup_preset(registry: EnvironmentRegistry, name: str, preset: str) -> Preset:
|
|
244
|
+
"""Environment ``name``'s preset ``preset``; 404 for either unknown, 422 for a bad declaration."""
|
|
245
|
+
presets = environment_presets(registry, name)
|
|
246
|
+
if preset not in presets:
|
|
247
|
+
raise HTTPException(
|
|
248
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
249
|
+
detail=f"Environment '{name}' has no preset '{preset}'. Available: {sorted(presets)}",
|
|
250
|
+
)
|
|
251
|
+
return presets[preset]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def environment_presets(registry: EnvironmentRegistry, name: str) -> dict[str, Preset]:
|
|
255
|
+
"""Environment ``name``'s presets; 404 when it is unknown, 422 for a bad declaration."""
|
|
256
|
+
try:
|
|
257
|
+
return registry.get_presets(name)
|
|
258
|
+
except KeyError as e:
|
|
259
|
+
raise HTTPException(
|
|
260
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
261
|
+
detail=f"Environment '{name}' not found",
|
|
262
|
+
) from e
|
|
263
|
+
except ValueError as e:
|
|
264
|
+
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e)) from e
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def validate_variable_names(
|
|
268
|
+
names: list[str],
|
|
269
|
+
env: EnvironmentProtocol,
|
|
270
|
+
field: str = "variable",
|
|
271
|
+
) -> None:
|
|
272
|
+
"""Raise 422 if any name is not in the environment metadata.
|
|
273
|
+
|
|
274
|
+
``field`` labels the offending input so callers passing different fields
|
|
275
|
+
(e.g. ``overrides`` vs ``current_values``) get distinguishable messages.
|
|
276
|
+
"""
|
|
277
|
+
unknown = [n for n in names if n not in env.variables]
|
|
278
|
+
if unknown:
|
|
279
|
+
raise HTTPException(
|
|
280
|
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
281
|
+
detail=f"Unknown {field}(s): {unknown}",
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def apply_variables_to_environment(
|
|
286
|
+
env: EnvironmentProtocol, name: str, variables: dict[str, float]
|
|
287
|
+
) -> None:
|
|
288
|
+
"""Write variables to the machine, mapping control errors to HTTP errors.
|
|
289
|
+
|
|
290
|
+
Owns the environment context: pass a freshly-loaded (not already-open)
|
|
291
|
+
``env`` from :func:`load_environment`; this opens and closes it.
|
|
292
|
+
"""
|
|
293
|
+
with write_errors(name, "set variables"), env:
|
|
294
|
+
env.set_variables(variables)
|