runops 0.2.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.
- runops/__init__.py +5 -0
- runops/_data/README.md +476 -0
- runops/adapters/__init__.py +29 -0
- runops/adapters/_utils/__init__.py +36 -0
- runops/adapters/_utils/toml_utils.py +81 -0
- runops/adapters/base.py +335 -0
- runops/adapters/contrib/__init__.py +5 -0
- runops/adapters/contrib/beach.py +837 -0
- runops/adapters/contrib/emses.py +1010 -0
- runops/adapters/generic.py +439 -0
- runops/adapters/registry.py +244 -0
- runops/cli/__init__.py +3 -0
- runops/cli/analyze.py +222 -0
- runops/cli/clone.py +104 -0
- runops/cli/config.py +217 -0
- runops/cli/context.py +56 -0
- runops/cli/create.py +263 -0
- runops/cli/dashboard.py +179 -0
- runops/cli/extend.py +204 -0
- runops/cli/history.py +105 -0
- runops/cli/init.py +1432 -0
- runops/cli/jobs.py +145 -0
- runops/cli/knowledge.py +1017 -0
- runops/cli/list.py +102 -0
- runops/cli/log.py +163 -0
- runops/cli/main.py +96 -0
- runops/cli/manage.py +231 -0
- runops/cli/new.py +343 -0
- runops/cli/notes.py +257 -0
- runops/cli/run_lookup.py +148 -0
- runops/cli/setup.py +174 -0
- runops/cli/status.py +187 -0
- runops/cli/submit.py +297 -0
- runops/cli/update.py +113 -0
- runops/cli/update_harness.py +245 -0
- runops/cli/update_refs.py +370 -0
- runops/core/__init__.py +3 -0
- runops/core/actions.py +1186 -0
- runops/core/analysis.py +1090 -0
- runops/core/campaign.py +156 -0
- runops/core/case.py +307 -0
- runops/core/context.py +426 -0
- runops/core/discovery.py +192 -0
- runops/core/environment.py +266 -0
- runops/core/exceptions.py +93 -0
- runops/core/knowledge.py +595 -0
- runops/core/knowledge_source.py +1204 -0
- runops/core/manifest.py +219 -0
- runops/core/project.py +171 -0
- runops/core/provenance.py +147 -0
- runops/core/retry.py +193 -0
- runops/core/run.py +170 -0
- runops/core/run_creation.py +456 -0
- runops/core/site.py +337 -0
- runops/core/state.py +197 -0
- runops/core/survey.py +380 -0
- runops/core/validation.py +40 -0
- runops/harness/__init__.py +27 -0
- runops/harness/builder.py +327 -0
- runops/harness/claude.py +189 -0
- runops/jobgen/__init__.py +3 -0
- runops/jobgen/generator.py +295 -0
- runops/launchers/__init__.py +17 -0
- runops/launchers/base.py +313 -0
- runops/launchers/mpiexec.py +131 -0
- runops/launchers/mpirun.py +132 -0
- runops/launchers/srun.py +126 -0
- runops/sites/__init__.py +0 -0
- runops/sites/camphor.md +98 -0
- runops/sites/camphor.toml +27 -0
- runops/slurm/__init__.py +3 -0
- runops/slurm/query.py +384 -0
- runops/slurm/submit.py +203 -0
- runops/templates/__init__.py +29 -0
- runops/templates/adapters/beach/agent_guide.md +50 -0
- runops/templates/adapters/beach/beach.toml +19 -0
- runops/templates/adapters/beach/case.toml +16 -0
- runops/templates/adapters/beach/summarize.py +272 -0
- runops/templates/adapters/emses/agent_guide.md +39 -0
- runops/templates/adapters/emses/case.toml +18 -0
- runops/templates/adapters/emses/plasma.toml +118 -0
- runops/templates/adapters/emses/summarize.py +413 -0
- runops/templates/adapters/generic/case.toml.j2 +13 -0
- runops/templates/adapters/generic/summarize.py +21 -0
- runops/templates/agent.md +156 -0
- runops/templates/rules/cookbook.md +22 -0
- runops/templates/scaffold/campaign.toml.j2 +10 -0
- runops/templates/scaffold/cases_claude.md +22 -0
- runops/templates/scaffold/facts.toml +2 -0
- runops/templates/scaffold/gitignore.txt +30 -0
- runops/templates/scaffold/notes/README.md +69 -0
- runops/templates/scaffold/rules/plan-before-act.md +17 -0
- runops/templates/scaffold/rules/runops-workflow.md +84 -0
- runops/templates/scaffold/rules/upstream-feedback.md +85 -0
- runops/templates/scaffold/runs_claude.md +24 -0
- runops/templates/scaffold/vscode_settings.json +9 -0
- runops/templates/skills/analyze/SKILL.md +40 -0
- runops/templates/skills/check-status/SKILL.md +29 -0
- runops/templates/skills/cleanup/SKILL.md +43 -0
- runops/templates/skills/create-run/SKILL.md +135 -0
- runops/templates/skills/debug-failed/SKILL.md +38 -0
- runops/templates/skills/learn/SKILL.md +54 -0
- runops/templates/skills/new-case/SKILL.md +108 -0
- runops/templates/skills/note/SKILL.md +107 -0
- runops/templates/skills/run-all/SKILL.md +47 -0
- runops/templates/skills/runops-reference/SKILL.md +203 -0
- runops/templates/skills/setup-campaign/SKILL.md +111 -0
- runops/templates/skills/setup-env/SKILL.md +32 -0
- runops/templates/skills/survey-design/SKILL.md +73 -0
- runops/templates/survey.toml.j2 +22 -0
- runops-0.2.0.dist-info/METADATA +491 -0
- runops-0.2.0.dist-info/RECORD +115 -0
- runops-0.2.0.dist-info/WHEEL +4 -0
- runops-0.2.0.dist-info/entry_points.txt +2 -0
- runops-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""Generic simulator adapter providing reasonable defaults.
|
|
2
|
+
|
|
3
|
+
This adapter handles simulators that follow common conventions:
|
|
4
|
+
a single executable, a single input file, and standard output
|
|
5
|
+
locations within the ``work/`` directory.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from runops.adapters.base import SimulatorAdapter
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Constants
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
INPUT_DIR = "input"
|
|
27
|
+
WORK_DIR = "work"
|
|
28
|
+
ANALYSIS_DIR = "analysis"
|
|
29
|
+
STATUS_DIR = "status"
|
|
30
|
+
SUBMIT_DIR = "submit"
|
|
31
|
+
|
|
32
|
+
EXIT_CODE_FILE = "exit_code"
|
|
33
|
+
STDOUT_FILE = "stdout.log"
|
|
34
|
+
STDERR_FILE = "stderr.log"
|
|
35
|
+
|
|
36
|
+
SUMMARY_FILE = "summary.json"
|
|
37
|
+
|
|
38
|
+
_RESOLVER_MODES = frozenset({"package", "local_source", "local_executable"})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GenericAdapter(SimulatorAdapter):
|
|
42
|
+
"""Generic adapter that works for many simple simulators.
|
|
43
|
+
|
|
44
|
+
Conventions:
|
|
45
|
+
- Input files live in ``<run_dir>/input/``.
|
|
46
|
+
- Execution happens in ``<run_dir>/work/``.
|
|
47
|
+
- Success is determined by a zero exit code written to
|
|
48
|
+
``<run_dir>/work/exit_code``.
|
|
49
|
+
|
|
50
|
+
Class Attributes:
|
|
51
|
+
adapter_name: Registry key for this adapter.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
adapter_name: str = "generic"
|
|
55
|
+
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
# SimulatorAdapter interface
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def name(self) -> str:
|
|
62
|
+
"""Return the canonical name of this adapter."""
|
|
63
|
+
return self.adapter_name
|
|
64
|
+
|
|
65
|
+
def render_inputs(
|
|
66
|
+
self,
|
|
67
|
+
case_data: dict[str, Any],
|
|
68
|
+
run_dir: Path,
|
|
69
|
+
) -> list[str]:
|
|
70
|
+
"""Copy input files listed in *case_data* into the run directory.
|
|
71
|
+
|
|
72
|
+
If ``case_data["params"]`` exists, it is serialised as a JSON file
|
|
73
|
+
named ``params.json`` inside ``<run_dir>/input/``.
|
|
74
|
+
|
|
75
|
+
If ``case_data["case"]["input_files"]`` is a list of source paths,
|
|
76
|
+
each file is copied into ``<run_dir>/input/``.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
case_data: Merged case/survey parameters.
|
|
80
|
+
run_dir: Target run directory.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
List of relative paths (from *run_dir*) to generated input files.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
ValueError: If required case metadata is missing.
|
|
87
|
+
"""
|
|
88
|
+
case_section = case_data.get("case", {})
|
|
89
|
+
if not case_section:
|
|
90
|
+
msg = "case_data must contain a 'case' section"
|
|
91
|
+
raise ValueError(msg)
|
|
92
|
+
|
|
93
|
+
input_dir = run_dir / INPUT_DIR
|
|
94
|
+
input_dir.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
|
|
96
|
+
created: list[str] = []
|
|
97
|
+
|
|
98
|
+
# Serialise parameters
|
|
99
|
+
params = case_data.get("params")
|
|
100
|
+
if params is not None:
|
|
101
|
+
params_file = input_dir / "params.json"
|
|
102
|
+
params_file.write_text(json.dumps(params, indent=2))
|
|
103
|
+
created.append(str(params_file.relative_to(run_dir)))
|
|
104
|
+
|
|
105
|
+
# Copy explicit input files
|
|
106
|
+
source_files: list[str] = case_section.get("input_files", [])
|
|
107
|
+
for src_str in source_files:
|
|
108
|
+
src = Path(src_str)
|
|
109
|
+
if not src.is_file():
|
|
110
|
+
logger.warning("Input file not found, skipping: %s", src)
|
|
111
|
+
continue
|
|
112
|
+
dest = input_dir / src.name
|
|
113
|
+
shutil.copy2(src, dest)
|
|
114
|
+
created.append(str(dest.relative_to(run_dir)))
|
|
115
|
+
|
|
116
|
+
return created
|
|
117
|
+
|
|
118
|
+
def resolve_runtime(
|
|
119
|
+
self,
|
|
120
|
+
simulator_config: dict[str, Any],
|
|
121
|
+
resolver_mode: str,
|
|
122
|
+
) -> dict[str, Any]:
|
|
123
|
+
"""Resolve the simulator runtime from *simulator_config*.
|
|
124
|
+
|
|
125
|
+
Supports three resolver modes defined in SPEC section 16:
|
|
126
|
+
|
|
127
|
+
* ``package`` -- looks up ``executable`` in ``$PATH``.
|
|
128
|
+
* ``local_source`` -- uses ``source_repo`` and optionally runs
|
|
129
|
+
``build_command`` to produce ``executable``.
|
|
130
|
+
* ``local_executable`` -- uses a fully-qualified ``executable``
|
|
131
|
+
path directly.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
simulator_config: Simulator section from ``simulators.toml``.
|
|
135
|
+
resolver_mode: One of ``"package"``, ``"local_source"``,
|
|
136
|
+
``"local_executable"``.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Runtime information dictionary with at least ``executable``
|
|
140
|
+
and ``resolver_mode`` keys.
|
|
141
|
+
|
|
142
|
+
Raises:
|
|
143
|
+
ValueError: If *resolver_mode* is unsupported or required keys
|
|
144
|
+
are missing.
|
|
145
|
+
"""
|
|
146
|
+
if resolver_mode not in _RESOLVER_MODES:
|
|
147
|
+
msg = (
|
|
148
|
+
f"Unsupported resolver_mode '{resolver_mode}'. "
|
|
149
|
+
f"Expected one of {sorted(_RESOLVER_MODES)}"
|
|
150
|
+
)
|
|
151
|
+
raise ValueError(msg)
|
|
152
|
+
|
|
153
|
+
runtime: dict[str, Any] = {"resolver_mode": resolver_mode}
|
|
154
|
+
|
|
155
|
+
if resolver_mode == "package":
|
|
156
|
+
exe_name = simulator_config.get("executable", "")
|
|
157
|
+
if not exe_name:
|
|
158
|
+
msg = "simulator_config must specify 'executable' for package mode"
|
|
159
|
+
raise ValueError(msg)
|
|
160
|
+
resolved = shutil.which(exe_name)
|
|
161
|
+
runtime["executable"] = resolved if resolved else exe_name
|
|
162
|
+
runtime["source"] = "package"
|
|
163
|
+
|
|
164
|
+
elif resolver_mode == "local_source":
|
|
165
|
+
source_repo = simulator_config.get("source_repo", "")
|
|
166
|
+
executable = simulator_config.get("executable", "")
|
|
167
|
+
if not source_repo or not executable:
|
|
168
|
+
msg = (
|
|
169
|
+
"simulator_config must specify 'source_repo' and "
|
|
170
|
+
"'executable' for local_source mode"
|
|
171
|
+
)
|
|
172
|
+
raise ValueError(msg)
|
|
173
|
+
runtime["source_repo"] = source_repo
|
|
174
|
+
runtime["executable"] = executable
|
|
175
|
+
runtime["build_command"] = simulator_config.get("build_command", "")
|
|
176
|
+
|
|
177
|
+
elif resolver_mode == "local_executable":
|
|
178
|
+
executable = simulator_config.get("executable", "")
|
|
179
|
+
if not executable:
|
|
180
|
+
msg = (
|
|
181
|
+
"simulator_config must specify 'executable' "
|
|
182
|
+
"for local_executable mode"
|
|
183
|
+
)
|
|
184
|
+
raise ValueError(msg)
|
|
185
|
+
runtime["executable"] = executable
|
|
186
|
+
|
|
187
|
+
return runtime
|
|
188
|
+
|
|
189
|
+
def build_program_command(
|
|
190
|
+
self,
|
|
191
|
+
runtime_info: dict[str, Any],
|
|
192
|
+
run_dir: Path,
|
|
193
|
+
) -> list[str]:
|
|
194
|
+
"""Build ``[executable, ...]`` for the simulator.
|
|
195
|
+
|
|
196
|
+
The generic adapter simply returns the executable path. If an
|
|
197
|
+
``input/params.json`` file exists it is passed as the first
|
|
198
|
+
positional argument.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
runtime_info: Output from :meth:`resolve_runtime`.
|
|
202
|
+
run_dir: The run directory.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Command as a list of strings (no MPI launcher prefix).
|
|
206
|
+
"""
|
|
207
|
+
executable: str = runtime_info.get("executable", "")
|
|
208
|
+
if not executable:
|
|
209
|
+
msg = "runtime_info must contain 'executable'"
|
|
210
|
+
raise ValueError(msg)
|
|
211
|
+
|
|
212
|
+
cmd = [executable]
|
|
213
|
+
params_file = run_dir / INPUT_DIR / "params.json"
|
|
214
|
+
if params_file.exists():
|
|
215
|
+
cmd.append(str(params_file))
|
|
216
|
+
return cmd
|
|
217
|
+
|
|
218
|
+
def detect_outputs(self, run_dir: Path) -> dict[str, Any]:
|
|
219
|
+
"""Scan ``<run_dir>/work/`` for output files.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
run_dir: The run directory.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Mapping of descriptive label to relative path strings.
|
|
226
|
+
"""
|
|
227
|
+
work_dir = run_dir / WORK_DIR
|
|
228
|
+
if not work_dir.is_dir():
|
|
229
|
+
logger.warning("work/ directory not found in %s", run_dir)
|
|
230
|
+
return {}
|
|
231
|
+
|
|
232
|
+
outputs: dict[str, Any] = {}
|
|
233
|
+
stdout = work_dir / STDOUT_FILE
|
|
234
|
+
stderr = work_dir / STDERR_FILE
|
|
235
|
+
|
|
236
|
+
if stdout.exists():
|
|
237
|
+
outputs["stdout"] = str(stdout.relative_to(run_dir))
|
|
238
|
+
if stderr.exists():
|
|
239
|
+
outputs["stderr"] = str(stderr.relative_to(run_dir))
|
|
240
|
+
|
|
241
|
+
# Collect remaining files (excluding status markers)
|
|
242
|
+
for path in sorted(work_dir.iterdir()):
|
|
243
|
+
if path.name in {STDOUT_FILE, STDERR_FILE, EXIT_CODE_FILE}:
|
|
244
|
+
continue
|
|
245
|
+
if path.is_file():
|
|
246
|
+
outputs[path.stem] = str(path.relative_to(run_dir))
|
|
247
|
+
elif path.is_dir():
|
|
248
|
+
outputs[path.name] = str(path.relative_to(run_dir))
|
|
249
|
+
|
|
250
|
+
return outputs
|
|
251
|
+
|
|
252
|
+
def detect_status(self, run_dir: Path) -> str:
|
|
253
|
+
"""Determine simulation status from ``work/exit_code`` and logs.
|
|
254
|
+
|
|
255
|
+
The detection logic is:
|
|
256
|
+
|
|
257
|
+
1. If ``work/exit_code`` exists and contains ``0``, return
|
|
258
|
+
``"completed"``.
|
|
259
|
+
2. If ``work/exit_code`` exists with a non-zero value, return
|
|
260
|
+
``"failed"``.
|
|
261
|
+
3. If ``work/`` contains output files but no exit code, return
|
|
262
|
+
``"running"``.
|
|
263
|
+
4. Otherwise return ``"unknown"``.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
run_dir: The run directory.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
A status string.
|
|
270
|
+
"""
|
|
271
|
+
work_dir = run_dir / WORK_DIR
|
|
272
|
+
exit_code_path = work_dir / EXIT_CODE_FILE
|
|
273
|
+
|
|
274
|
+
if exit_code_path.exists():
|
|
275
|
+
try:
|
|
276
|
+
code = int(exit_code_path.read_text().strip())
|
|
277
|
+
except (ValueError, OSError):
|
|
278
|
+
return "unknown"
|
|
279
|
+
return "completed" if code == 0 else "failed"
|
|
280
|
+
|
|
281
|
+
# No exit code yet -- check whether work/ has any content
|
|
282
|
+
if work_dir.is_dir() and any(work_dir.iterdir()):
|
|
283
|
+
return "running"
|
|
284
|
+
|
|
285
|
+
return "unknown"
|
|
286
|
+
|
|
287
|
+
def summarize(self, run_dir: Path) -> dict[str, Any]:
|
|
288
|
+
"""Produce a basic summary of the run.
|
|
289
|
+
|
|
290
|
+
For the generic adapter this includes:
|
|
291
|
+
- detected outputs (file listing)
|
|
292
|
+
- detected status
|
|
293
|
+
- exit code (if available)
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
run_dir: The run directory.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
Summary dictionary.
|
|
300
|
+
"""
|
|
301
|
+
summary: dict[str, Any] = {}
|
|
302
|
+
errors: list[str] = []
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
summary["status"] = self.detect_status(run_dir)
|
|
306
|
+
except Exception as exc:
|
|
307
|
+
errors.append(f"detect_status: {exc}")
|
|
308
|
+
summary["status"] = "unknown"
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
summary["outputs"] = self.detect_outputs(run_dir)
|
|
312
|
+
except Exception as exc:
|
|
313
|
+
errors.append(f"detect_outputs: {exc}")
|
|
314
|
+
summary["outputs"] = {}
|
|
315
|
+
|
|
316
|
+
exit_code_path = run_dir / WORK_DIR / EXIT_CODE_FILE
|
|
317
|
+
if exit_code_path.exists():
|
|
318
|
+
try:
|
|
319
|
+
summary["exit_code"] = int(exit_code_path.read_text().strip())
|
|
320
|
+
except (ValueError, OSError) as exc:
|
|
321
|
+
errors.append(f"exit_code: {exc}")
|
|
322
|
+
|
|
323
|
+
if errors:
|
|
324
|
+
summary["errors"] = errors
|
|
325
|
+
|
|
326
|
+
return summary
|
|
327
|
+
|
|
328
|
+
def collect_provenance(
|
|
329
|
+
self,
|
|
330
|
+
runtime_info: dict[str, Any],
|
|
331
|
+
) -> dict[str, Any]:
|
|
332
|
+
"""Collect provenance information from *runtime_info*.
|
|
333
|
+
|
|
334
|
+
Gathers:
|
|
335
|
+
- resolver mode
|
|
336
|
+
- executable path and SHA-256 hash (if the file exists)
|
|
337
|
+
- source repository git commit (if ``local_source`` mode)
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
runtime_info: Output from :meth:`resolve_runtime`.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
Provenance dictionary.
|
|
344
|
+
"""
|
|
345
|
+
provenance: dict[str, Any] = {
|
|
346
|
+
"resolver_mode": runtime_info.get("resolver_mode", ""),
|
|
347
|
+
"executable": runtime_info.get("executable", ""),
|
|
348
|
+
"exe_hash": "",
|
|
349
|
+
"git_commit": "",
|
|
350
|
+
"git_dirty": False,
|
|
351
|
+
"source_repo": runtime_info.get("source_repo", ""),
|
|
352
|
+
"build_command": runtime_info.get("build_command", ""),
|
|
353
|
+
"package_version": runtime_info.get("package_version", ""),
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
# Executable hash
|
|
357
|
+
exe_path = Path(runtime_info.get("executable", ""))
|
|
358
|
+
if exe_path.is_file():
|
|
359
|
+
provenance["exe_hash"] = _compute_file_hash(exe_path)
|
|
360
|
+
|
|
361
|
+
# Git provenance for local_source
|
|
362
|
+
if runtime_info.get("resolver_mode") == "local_source":
|
|
363
|
+
repo = runtime_info.get("source_repo", "")
|
|
364
|
+
if repo:
|
|
365
|
+
git_info = _collect_git_info(Path(repo))
|
|
366
|
+
provenance["git_commit"] = git_info.get("commit", "")
|
|
367
|
+
provenance["git_dirty"] = git_info.get("dirty", False)
|
|
368
|
+
|
|
369
|
+
return provenance
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
# Internal helpers
|
|
374
|
+
# ---------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _compute_file_hash(path: Path) -> str:
|
|
378
|
+
"""Return ``sha256:<hex>`` hash of a file.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
path: Path to the file.
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
Hash string prefixed with ``sha256:``.
|
|
385
|
+
"""
|
|
386
|
+
h = hashlib.sha256()
|
|
387
|
+
with path.open("rb") as f:
|
|
388
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
|
389
|
+
h.update(chunk)
|
|
390
|
+
return f"sha256:{h.hexdigest()}"
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _collect_git_info(repo_path: Path) -> dict[str, Any]:
|
|
394
|
+
"""Collect basic git info from a repository path.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
repo_path: Path to the git repository root.
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
Dictionary with ``commit``, ``dirty``, and ``branch`` keys.
|
|
401
|
+
Returns empty values on failure rather than raising.
|
|
402
|
+
"""
|
|
403
|
+
info: dict[str, Any] = {"commit": "", "dirty": False, "branch": ""}
|
|
404
|
+
if not repo_path.is_dir():
|
|
405
|
+
return info
|
|
406
|
+
try:
|
|
407
|
+
result = subprocess.run(
|
|
408
|
+
["git", "rev-parse", "HEAD"],
|
|
409
|
+
capture_output=True,
|
|
410
|
+
text=True,
|
|
411
|
+
cwd=repo_path,
|
|
412
|
+
check=False,
|
|
413
|
+
)
|
|
414
|
+
if result.returncode == 0:
|
|
415
|
+
info["commit"] = result.stdout.strip()
|
|
416
|
+
|
|
417
|
+
result = subprocess.run(
|
|
418
|
+
["git", "status", "--porcelain"],
|
|
419
|
+
capture_output=True,
|
|
420
|
+
text=True,
|
|
421
|
+
cwd=repo_path,
|
|
422
|
+
check=False,
|
|
423
|
+
)
|
|
424
|
+
if result.returncode == 0:
|
|
425
|
+
info["dirty"] = bool(result.stdout.strip())
|
|
426
|
+
|
|
427
|
+
result = subprocess.run(
|
|
428
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
429
|
+
capture_output=True,
|
|
430
|
+
text=True,
|
|
431
|
+
cwd=repo_path,
|
|
432
|
+
check=False,
|
|
433
|
+
)
|
|
434
|
+
if result.returncode == 0:
|
|
435
|
+
info["branch"] = result.stdout.strip()
|
|
436
|
+
except FileNotFoundError:
|
|
437
|
+
logger.debug("git not found on PATH; skipping git provenance")
|
|
438
|
+
|
|
439
|
+
return info
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Adapter registry for looking up simulator adapters by name.
|
|
2
|
+
|
|
3
|
+
Provides both a module-level functional API and an ``AdapterRegistry`` class
|
|
4
|
+
for explicit lifetime control. The module-level functions delegate to a
|
|
5
|
+
shared global instance.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import logging
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from runops.adapters.base import SimulatorAdapter
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AdapterRegistry:
|
|
21
|
+
"""Registry that maps simulator names to adapter classes.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
_entries: Internal mapping from name to adapter class.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._entries: dict[str, type[SimulatorAdapter]] = {}
|
|
29
|
+
|
|
30
|
+
# ------------------------------------------------------------------
|
|
31
|
+
# Registration
|
|
32
|
+
# ------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def register(
|
|
35
|
+
self,
|
|
36
|
+
adapter_cls: type[SimulatorAdapter],
|
|
37
|
+
*,
|
|
38
|
+
name: str | None = None,
|
|
39
|
+
) -> type[SimulatorAdapter]:
|
|
40
|
+
"""Register a simulator adapter class.
|
|
41
|
+
|
|
42
|
+
Can be used as a decorator::
|
|
43
|
+
|
|
44
|
+
@registry.register
|
|
45
|
+
class MyAdapter(SimulatorAdapter):
|
|
46
|
+
adapter_name = "my_sim"
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
adapter_cls: The adapter class to register.
|
|
50
|
+
name: Explicit adapter name. If not provided, reads the
|
|
51
|
+
``name`` property from a temporary instance or the
|
|
52
|
+
``adapter_name`` class attribute.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
The adapter class (unchanged).
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ValueError: If an adapter with the same name is already
|
|
59
|
+
registered.
|
|
60
|
+
AttributeError: If no name can be resolved.
|
|
61
|
+
"""
|
|
62
|
+
resolved_name = self._resolve_name(adapter_cls, name)
|
|
63
|
+
if resolved_name in self._entries:
|
|
64
|
+
msg = f"Adapter already registered: {resolved_name}"
|
|
65
|
+
raise ValueError(msg)
|
|
66
|
+
self._entries[resolved_name] = adapter_cls
|
|
67
|
+
logger.debug(
|
|
68
|
+
"Registered adapter '%s' -> %s", resolved_name, adapter_cls.__name__
|
|
69
|
+
)
|
|
70
|
+
return adapter_cls
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
# Lookup
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def get(self, name: str) -> type[SimulatorAdapter]:
|
|
77
|
+
"""Retrieve a registered adapter class by name.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
name: Canonical adapter name.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
The adapter class.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
KeyError: If no adapter is registered under the given name.
|
|
87
|
+
"""
|
|
88
|
+
if name not in self._entries:
|
|
89
|
+
msg = f"Unknown adapter: {name}. Available: {self.list_adapters()}"
|
|
90
|
+
raise KeyError(msg)
|
|
91
|
+
return self._entries[name]
|
|
92
|
+
|
|
93
|
+
def list_adapters(self) -> list[str]:
|
|
94
|
+
"""Return the names of all registered adapters.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Sorted list of adapter names.
|
|
98
|
+
"""
|
|
99
|
+
return sorted(self._entries)
|
|
100
|
+
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
# Config loading
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def load_from_config(self, simulators_config: dict[str, Any]) -> None:
|
|
106
|
+
"""Auto-register adapters referenced in a simulators.toml config.
|
|
107
|
+
|
|
108
|
+
For each simulator entry that specifies an ``adapter`` key, this
|
|
109
|
+
method attempts to import and register the adapter module. Entries
|
|
110
|
+
whose adapter is already registered are silently skipped.
|
|
111
|
+
|
|
112
|
+
The import convention is::
|
|
113
|
+
|
|
114
|
+
runops.adapters.<adapter_name>
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
simulators_config: The parsed ``[simulators]`` table from
|
|
118
|
+
``simulators.toml``.
|
|
119
|
+
"""
|
|
120
|
+
simulators = simulators_config.get("simulators", simulators_config)
|
|
121
|
+
for sim_name, sim_cfg in simulators.items():
|
|
122
|
+
if not isinstance(sim_cfg, dict):
|
|
123
|
+
continue
|
|
124
|
+
adapter_name = sim_cfg.get("adapter", "")
|
|
125
|
+
if not adapter_name or adapter_name in self._entries:
|
|
126
|
+
continue
|
|
127
|
+
# Try contrib/ first, then top-level adapters/
|
|
128
|
+
imported = False
|
|
129
|
+
for module_path in (
|
|
130
|
+
f"runops.adapters.contrib.{adapter_name}",
|
|
131
|
+
f"runops.adapters.{adapter_name}",
|
|
132
|
+
):
|
|
133
|
+
try:
|
|
134
|
+
importlib.import_module(module_path)
|
|
135
|
+
logger.debug(
|
|
136
|
+
"Auto-imported adapter module '%s' for simulator '%s'",
|
|
137
|
+
module_path,
|
|
138
|
+
sim_name,
|
|
139
|
+
)
|
|
140
|
+
imported = True
|
|
141
|
+
break
|
|
142
|
+
except ImportError:
|
|
143
|
+
continue
|
|
144
|
+
if not imported:
|
|
145
|
+
logger.warning(
|
|
146
|
+
"Could not import adapter for simulator '%s'",
|
|
147
|
+
sim_name,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
# Helpers
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def _resolve_name(
|
|
156
|
+
adapter_cls: type[SimulatorAdapter],
|
|
157
|
+
explicit_name: str | None,
|
|
158
|
+
) -> str:
|
|
159
|
+
"""Determine the canonical name for an adapter class.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
adapter_cls: The adapter class.
|
|
163
|
+
explicit_name: An explicitly provided name, if any.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
The resolved adapter name.
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
AttributeError: If no name can be determined.
|
|
170
|
+
"""
|
|
171
|
+
if explicit_name is not None:
|
|
172
|
+
return explicit_name
|
|
173
|
+
# Try class-level adapter_name attribute first
|
|
174
|
+
attr_name: str = getattr(adapter_cls, "adapter_name", "")
|
|
175
|
+
if attr_name:
|
|
176
|
+
return attr_name
|
|
177
|
+
msg = (
|
|
178
|
+
f"{adapter_cls.__name__} must define 'adapter_name' "
|
|
179
|
+
"or pass 'name' to register()"
|
|
180
|
+
)
|
|
181
|
+
raise AttributeError(msg)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ------------------------------------------------------------------
|
|
185
|
+
# Global registry and module-level convenience functions
|
|
186
|
+
# ------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
_global_registry = AdapterRegistry()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def register(
|
|
192
|
+
adapter_cls: type[SimulatorAdapter],
|
|
193
|
+
*,
|
|
194
|
+
name: str | None = None,
|
|
195
|
+
) -> type[SimulatorAdapter]:
|
|
196
|
+
"""Register an adapter in the global registry.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
adapter_cls: The adapter class to register.
|
|
200
|
+
name: Explicit adapter name.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
The adapter class (unchanged).
|
|
204
|
+
"""
|
|
205
|
+
return _global_registry.register(adapter_cls, name=name)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def get(name: str) -> type[SimulatorAdapter]:
|
|
209
|
+
"""Retrieve an adapter class from the global registry.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
name: Canonical adapter name.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
The adapter class.
|
|
216
|
+
"""
|
|
217
|
+
return _global_registry.get(name)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def list_adapters() -> list[str]:
|
|
221
|
+
"""Return all adapter names in the global registry.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Sorted list of adapter names.
|
|
225
|
+
"""
|
|
226
|
+
return _global_registry.list_adapters()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def load_from_config(simulators_config: dict[str, Any]) -> None:
|
|
230
|
+
"""Load adapters into the global registry from simulators.toml config.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
simulators_config: The parsed simulators configuration.
|
|
234
|
+
"""
|
|
235
|
+
_global_registry.load_from_config(simulators_config)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def get_global_registry() -> AdapterRegistry:
|
|
239
|
+
"""Return the global ``AdapterRegistry`` instance.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
The shared global registry.
|
|
243
|
+
"""
|
|
244
|
+
return _global_registry
|
runops/cli/__init__.py
ADDED