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.
Files changed (115) hide show
  1. runops/__init__.py +5 -0
  2. runops/_data/README.md +476 -0
  3. runops/adapters/__init__.py +29 -0
  4. runops/adapters/_utils/__init__.py +36 -0
  5. runops/adapters/_utils/toml_utils.py +81 -0
  6. runops/adapters/base.py +335 -0
  7. runops/adapters/contrib/__init__.py +5 -0
  8. runops/adapters/contrib/beach.py +837 -0
  9. runops/adapters/contrib/emses.py +1010 -0
  10. runops/adapters/generic.py +439 -0
  11. runops/adapters/registry.py +244 -0
  12. runops/cli/__init__.py +3 -0
  13. runops/cli/analyze.py +222 -0
  14. runops/cli/clone.py +104 -0
  15. runops/cli/config.py +217 -0
  16. runops/cli/context.py +56 -0
  17. runops/cli/create.py +263 -0
  18. runops/cli/dashboard.py +179 -0
  19. runops/cli/extend.py +204 -0
  20. runops/cli/history.py +105 -0
  21. runops/cli/init.py +1432 -0
  22. runops/cli/jobs.py +145 -0
  23. runops/cli/knowledge.py +1017 -0
  24. runops/cli/list.py +102 -0
  25. runops/cli/log.py +163 -0
  26. runops/cli/main.py +96 -0
  27. runops/cli/manage.py +231 -0
  28. runops/cli/new.py +343 -0
  29. runops/cli/notes.py +257 -0
  30. runops/cli/run_lookup.py +148 -0
  31. runops/cli/setup.py +174 -0
  32. runops/cli/status.py +187 -0
  33. runops/cli/submit.py +297 -0
  34. runops/cli/update.py +113 -0
  35. runops/cli/update_harness.py +245 -0
  36. runops/cli/update_refs.py +370 -0
  37. runops/core/__init__.py +3 -0
  38. runops/core/actions.py +1186 -0
  39. runops/core/analysis.py +1090 -0
  40. runops/core/campaign.py +156 -0
  41. runops/core/case.py +307 -0
  42. runops/core/context.py +426 -0
  43. runops/core/discovery.py +192 -0
  44. runops/core/environment.py +266 -0
  45. runops/core/exceptions.py +93 -0
  46. runops/core/knowledge.py +595 -0
  47. runops/core/knowledge_source.py +1204 -0
  48. runops/core/manifest.py +219 -0
  49. runops/core/project.py +171 -0
  50. runops/core/provenance.py +147 -0
  51. runops/core/retry.py +193 -0
  52. runops/core/run.py +170 -0
  53. runops/core/run_creation.py +456 -0
  54. runops/core/site.py +337 -0
  55. runops/core/state.py +197 -0
  56. runops/core/survey.py +380 -0
  57. runops/core/validation.py +40 -0
  58. runops/harness/__init__.py +27 -0
  59. runops/harness/builder.py +327 -0
  60. runops/harness/claude.py +189 -0
  61. runops/jobgen/__init__.py +3 -0
  62. runops/jobgen/generator.py +295 -0
  63. runops/launchers/__init__.py +17 -0
  64. runops/launchers/base.py +313 -0
  65. runops/launchers/mpiexec.py +131 -0
  66. runops/launchers/mpirun.py +132 -0
  67. runops/launchers/srun.py +126 -0
  68. runops/sites/__init__.py +0 -0
  69. runops/sites/camphor.md +98 -0
  70. runops/sites/camphor.toml +27 -0
  71. runops/slurm/__init__.py +3 -0
  72. runops/slurm/query.py +384 -0
  73. runops/slurm/submit.py +203 -0
  74. runops/templates/__init__.py +29 -0
  75. runops/templates/adapters/beach/agent_guide.md +50 -0
  76. runops/templates/adapters/beach/beach.toml +19 -0
  77. runops/templates/adapters/beach/case.toml +16 -0
  78. runops/templates/adapters/beach/summarize.py +272 -0
  79. runops/templates/adapters/emses/agent_guide.md +39 -0
  80. runops/templates/adapters/emses/case.toml +18 -0
  81. runops/templates/adapters/emses/plasma.toml +118 -0
  82. runops/templates/adapters/emses/summarize.py +413 -0
  83. runops/templates/adapters/generic/case.toml.j2 +13 -0
  84. runops/templates/adapters/generic/summarize.py +21 -0
  85. runops/templates/agent.md +156 -0
  86. runops/templates/rules/cookbook.md +22 -0
  87. runops/templates/scaffold/campaign.toml.j2 +10 -0
  88. runops/templates/scaffold/cases_claude.md +22 -0
  89. runops/templates/scaffold/facts.toml +2 -0
  90. runops/templates/scaffold/gitignore.txt +30 -0
  91. runops/templates/scaffold/notes/README.md +69 -0
  92. runops/templates/scaffold/rules/plan-before-act.md +17 -0
  93. runops/templates/scaffold/rules/runops-workflow.md +84 -0
  94. runops/templates/scaffold/rules/upstream-feedback.md +85 -0
  95. runops/templates/scaffold/runs_claude.md +24 -0
  96. runops/templates/scaffold/vscode_settings.json +9 -0
  97. runops/templates/skills/analyze/SKILL.md +40 -0
  98. runops/templates/skills/check-status/SKILL.md +29 -0
  99. runops/templates/skills/cleanup/SKILL.md +43 -0
  100. runops/templates/skills/create-run/SKILL.md +135 -0
  101. runops/templates/skills/debug-failed/SKILL.md +38 -0
  102. runops/templates/skills/learn/SKILL.md +54 -0
  103. runops/templates/skills/new-case/SKILL.md +108 -0
  104. runops/templates/skills/note/SKILL.md +107 -0
  105. runops/templates/skills/run-all/SKILL.md +47 -0
  106. runops/templates/skills/runops-reference/SKILL.md +203 -0
  107. runops/templates/skills/setup-campaign/SKILL.md +111 -0
  108. runops/templates/skills/setup-env/SKILL.md +32 -0
  109. runops/templates/skills/survey-design/SKILL.md +73 -0
  110. runops/templates/survey.toml.j2 +22 -0
  111. runops-0.2.0.dist-info/METADATA +491 -0
  112. runops-0.2.0.dist-info/RECORD +115 -0
  113. runops-0.2.0.dist-info/WHEEL +4 -0
  114. runops-0.2.0.dist-info/entry_points.txt +2 -0
  115. 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
@@ -0,0 +1,3 @@
1
+ """CLI entry points for runops."""
2
+
3
+ from __future__ import annotations