runq-sdk 0.5.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.
runq/__init__.py ADDED
@@ -0,0 +1,90 @@
1
+ """runq — Lab GPU scheduler SDK (in-task client).
2
+
3
+ Public API::
4
+
5
+ import runq
6
+
7
+ ctx = runq.context()
8
+
9
+ # Typed params with auto merge from sweep
10
+ @runq.dataclass(auto_overwrite=True)
11
+ class Params:
12
+ lr: float = 0.001
13
+ batch_size: int = 32
14
+ cfg = Params()
15
+
16
+ # Training loop with auto step + preemption
17
+ for step in runq.range(100):
18
+ loss = train(model)
19
+ runq.log_metric("loss", loss) # step auto-populated
20
+ runq.report({"val_loss": evaluate(model)}) # early-stop check
21
+ runq.safe_save("ckpt.pt", model.state_dict())
22
+
23
+ # Resume from latest checkpoint
24
+ ckpt = runq.latest_checkpoint()
25
+ """
26
+
27
+ from ._config import dataclass
28
+ from ._context import Context, ParamDict, context, get_ctx
29
+ from ._events import log_metric
30
+ from ._exceptions import RunqDiskFullError, RunqEarlyStopSignal, RunqError
31
+ from ._loop import log_group, loop
32
+ from ._manifest import best_checkpoint, latest_checkpoint
33
+ from ._policies import convergence, patience, threshold
34
+ from ._range import is_preempted, range
35
+ from ._record import record
36
+ from ._report import Decision, early_stop, report
37
+ from ._safe_save import safe_save
38
+ from ._transport import TransportError
39
+ from . import utils
40
+
41
+ __all__ = [ # noqa: RUF022
42
+ # Init + context
43
+ "Context",
44
+ "ParamDict",
45
+ "context",
46
+ "get_ctx",
47
+ # Typed parameter dataclass
48
+ "dataclass",
49
+ # Training loop
50
+ "loop",
51
+ "range",
52
+ "is_preempted",
53
+ # Metrics
54
+ "log_metric",
55
+ "log_group",
56
+ # Results (bounded facts, stored in full — see _record.py)
57
+ "record",
58
+ # Early stop
59
+ "report",
60
+ "early_stop",
61
+ "Decision",
62
+ "patience",
63
+ "threshold",
64
+ "convergence",
65
+ # Checkpoint
66
+ "safe_save",
67
+ "best_checkpoint",
68
+ "latest_checkpoint",
69
+ # Exceptions
70
+ "RunqError",
71
+ "RunqDiskFullError",
72
+ "RunqEarlyStopSignal",
73
+ "TransportError",
74
+ ]
75
+
76
+ __version__ = "0.5.0"
77
+
78
+
79
+ def __getattr__(name: str):
80
+ """Module-level attribute access for convenience properties."""
81
+ if name == "preempted":
82
+ from ._range import is_preempted
83
+ return is_preempted()
84
+ if name == "seed":
85
+ from ._context import get_ctx
86
+ return get_ctx().seed
87
+ if name == "params":
88
+ from ._context import get_ctx
89
+ return get_ctx().params
90
+ raise AttributeError(f"module 'runq' has no attribute {name!r}")
runq/_config.py ADDED
@@ -0,0 +1,152 @@
1
+ """Typed parameter dataclass with automatic runq.params merging.
2
+
3
+ ``@runq.dataclass`` wraps a plain class into a ``dataclasses.dataclass``
4
+ with extras:
5
+
6
+ - **Pre-check**: all fields must have defaults — caught at class
7
+ definition time, not at runtime.
8
+ - **Auto-overwrite**: when ``auto_overwrite=True``, matching keys from
9
+ ``runq.params`` override defaults at instantiation time.
10
+ - **Serialization**: ``to_dict`` / ``to_json`` / ``from_json`` /
11
+ ``to_yaml`` / ``from_yaml`` out of the box.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import dataclasses
17
+ import json
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+
22
+ def dataclass(
23
+ cls=None,
24
+ *,
25
+ auto_overwrite: bool = False,
26
+ strict: bool = False,
27
+ ):
28
+ """Decorator that turns a class into a runq parameter dataclass.
29
+
30
+ All fields must have defaults. If ``auto_overwrite`` is True, matching
31
+ keys from ``runq.params`` override defaults at instantiation time.
32
+
33
+ Usage::
34
+
35
+ @runq.dataclass(auto_overwrite=True)
36
+ class MyConfig:
37
+ lr: float = 0.001
38
+ batch_size: int = 32
39
+
40
+ cfg = MyConfig() # lr overridden from runq.params if present
41
+ cfg.to_json("cfg.json")
42
+ """
43
+
44
+ def wrap(cls):
45
+ # Make it a dataclass if not already
46
+ if not dataclasses.is_dataclass(cls):
47
+ cls = dataclasses.dataclass(cls)
48
+
49
+ # Pre-check: every field must have a default
50
+ for f in dataclasses.fields(cls):
51
+ has_default = (
52
+ f.default is not dataclasses.MISSING
53
+ or f.default_factory is not dataclasses.MISSING
54
+ )
55
+ if not has_default:
56
+ raise TypeError(
57
+ f"@runq.dataclass: field '{f.name}' has no default. "
58
+ "All fields must have defaults so missing params are "
59
+ "caught at definition time, not at runtime."
60
+ )
61
+
62
+ # Store options on the class for introspection
63
+ cls._runq_auto_overwrite = auto_overwrite
64
+ cls._runq_strict = strict
65
+
66
+ # Wrap __init__ for auto_overwrite
67
+ if auto_overwrite:
68
+ original_init = cls.__init__
69
+
70
+ def new_init(self, *args, **kwargs):
71
+ original_init(self, *args, **kwargs)
72
+ _merge_params(self, strict)
73
+
74
+ cls.__init__ = new_init
75
+
76
+ # Add serialization methods
77
+ cls.to_dict = _to_dict
78
+ cls.to_json = _to_json
79
+ cls.from_json = classmethod(_from_json)
80
+ cls.to_yaml = _to_yaml
81
+ cls.from_yaml = classmethod(_from_yaml)
82
+
83
+ return cls
84
+
85
+ if cls is None:
86
+ return wrap
87
+ return wrap(cls)
88
+
89
+
90
+ def _merge_params(instance: Any, strict: bool) -> None:
91
+ """Merge runq.params into the dataclass instance."""
92
+ from runq._context import get_ctx
93
+
94
+ try:
95
+ ctx = get_ctx()
96
+ except RuntimeError:
97
+ return # no context initialized yet
98
+
99
+ fields = {f.name for f in dataclasses.fields(instance)}
100
+ for key, val in ctx.params.items():
101
+ if key in fields:
102
+ setattr(instance, key, val)
103
+ elif strict:
104
+ raise AttributeError(
105
+ f"Param '{key}' from runq.params not found in "
106
+ f"{type(instance).__name__}. "
107
+ f"Available fields: {sorted(fields)}. "
108
+ f"Set strict=False to ignore."
109
+ )
110
+ else:
111
+ continue
112
+
113
+
114
+ def _to_dict(self) -> dict[str, Any]:
115
+ """Convert to plain dict."""
116
+ return {f.name: getattr(self, f.name) for f in dataclasses.fields(self)}
117
+
118
+
119
+ def _to_json(self, path: str | Path | None = None, **kwargs) -> str:
120
+ """Serialize to JSON string. Optionally write to file."""
121
+ data = self.to_dict()
122
+ text = json.dumps(data, indent=2, ensure_ascii=False, **kwargs)
123
+ if path is not None:
124
+ Path(path).write_text(text, encoding="utf-8")
125
+ return text
126
+
127
+
128
+ def _from_json(cls, path: str | Path) -> Any:
129
+ """Load from a JSON file."""
130
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
131
+ known = {f.name for f in dataclasses.fields(cls)}
132
+ return cls(**{k: v for k, v in data.items() if k in known})
133
+
134
+
135
+ def _to_yaml(self, path: str | Path | None = None) -> str:
136
+ """Serialize to YAML string. Requires pyyaml."""
137
+ import yaml
138
+
139
+ data = self.to_dict()
140
+ text = yaml.dump(data, default_flow_style=False, allow_unicode=True)
141
+ if path is not None:
142
+ Path(path).write_text(text, encoding="utf-8")
143
+ return text
144
+
145
+
146
+ def _from_yaml(cls, path: str | Path) -> Any:
147
+ """Load from a YAML file. Requires pyyaml."""
148
+ import yaml
149
+
150
+ data = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
151
+ known = {f.name for f in dataclasses.fields(cls)}
152
+ return cls(**{k: v for k, v in data.items() if k in known})
runq/_context.py ADDED
@@ -0,0 +1,370 @@
1
+ """SDK Context object + initialization.
2
+
3
+ Three modes (see design doc §"Mode matrix"):
4
+
5
+ * daemon — RUNQ_TASK_ID set + RUNQ_SOCKET_PATH set + RUNQ_NO_DAEMON unset
6
+ * no_daemon — RUNQ_TASK_ID set + (RUNQ_SOCKET_PATH unset OR RUNQ_NO_DAEMON=1)
7
+ * manual — RUNQ_TASK_ID unset (running outside runq, local debug)
8
+
9
+ The module-level singleton `_ctx` is set by `context()` and consumed by
10
+ other SDK modules. Users get the Context back from `context()` and can
11
+ also call `get_ctx()` (mainly for internal use).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import uuid
18
+ import difflib
19
+ import hashlib
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ # Module-level singleton. None until context() is called.
25
+ _ctx: Context | None = None
26
+
27
+
28
+ class ParamDict(dict):
29
+ """Dict subclass with helpful KeyError messages for parameter access.
30
+
31
+ When a key is missing, shows all available keys and suggests close
32
+ matches via fuzzy matching — catches the common "sweep YAML says
33
+ ``learning_rate``, code says ``lr``" mismatch immediately.
34
+ """
35
+
36
+ def __getitem__(self, key):
37
+ try:
38
+ return super().__getitem__(key)
39
+ except KeyError:
40
+ available = sorted(self.keys())
41
+ close = difflib.get_close_matches(str(key), available, n=3, cutoff=0.5)
42
+ msg = f"'{key}' not found in runq.params.\nAvailable: {available}"
43
+ if close:
44
+ msg += f"\nDid you mean: {close}?"
45
+ raise KeyError(msg) from None
46
+
47
+
48
+ @dataclass
49
+ class Context:
50
+ """Per-task runtime context.
51
+
52
+ Populated from `RUNQ_*` env vars (daemon-injected) plus a local
53
+ `params.json`. Lazy users mostly interact via module-level helpers
54
+ (`runq.log_metric`, `runq.report`, ...) which pull state from the
55
+ singleton; this struct is the canonical home of all SDK state.
56
+
57
+ Fields are kept descriptive on purpose — debugging from `print(ctx)`
58
+ should be enough to see where the SDK thinks it lives.
59
+ """
60
+
61
+ mode: str # "daemon" / "no_daemon" / "manual"
62
+ task_id: str
63
+ job_id: str = ""
64
+ project: str = ""
65
+
66
+ # Filesystem paths (None when the relevant env var wasn't set).
67
+ task_dir: Path | None = None
68
+ checkpoint_dir: Path | None = None
69
+ metrics_file: Path | None = None
70
+ # Three-file contract (RQ2-1): results.jsonl (runq.record data plane)
71
+ # and events.jsonl (lifecycle plane) live NEXT TO metrics.jsonl. Both
72
+ # are derived from metrics_file rather than env-injected, so an older
73
+ # daemon that only sets RUNQ_METRICS_FILE still yields correct paths.
74
+ results_file: Path | None = None
75
+ events_file: Path | None = None
76
+
77
+ # daemon-only fields
78
+ socket_path: str | None = None
79
+
80
+ # SDK contract: daemon always injects these, so we read them with a
81
+ # default for the manual mode (where there's no daemon).
82
+ safety_factor_percent: int = 110
83
+ safety_extra_gb: int = 0
84
+
85
+ # Loaded from RUNQ_PARAMS_FILE (or local params.json in manual mode).
86
+ params: dict = field(default_factory=dict)
87
+
88
+ # ---- step bookkeeping (step 6) ----
89
+ #
90
+ # Single source of truth for "what step is this?". Maintained by:
91
+ # - ``report(metrics, step=N)`` — explicit step writes back here.
92
+ # - ``log_metric(..., step=N)`` — explicit step writes back here too,
93
+ # so the two helpers stay in sync.
94
+ # - ``loop()`` (step 8) — yields & sets per iteration.
95
+ #
96
+ # When None, log_metric / report write step=None to jsonl. There is
97
+ # NO auto-increment counter (unlike the early step 1 implementation);
98
+ # if you want incrementing steps without loop(), pass step explicitly.
99
+ current_step: int | None = None
100
+
101
+ # Deterministic seed derived from task_id. Different per task in the
102
+ # same sweep, but reproducible across reruns of the same task.
103
+ seed: int = 0
104
+
105
+ # ---- step-8 loop coordination ----
106
+ #
107
+ # Most-recent Decision returned by ``runq.report``. Read by
108
+ # ``runq.loop()`` after each iteration to decide whether to break.
109
+ # ``Any`` to dodge a forward ref to ``_report.Decision`` (would
110
+ # require a TYPE_CHECKING-only import dance).
111
+ _last_decision: Any | None = None
112
+
113
+ # ---- convenience accessors ----
114
+
115
+ def get(self, name: str, default: Any = None) -> Any:
116
+ """Look up a param with a fallback default.
117
+
118
+ Equivalent to ``ctx.params.get(name, default)`` — common enough to
119
+ deserve a shorthand.
120
+ """
121
+ return self.params.get(name, default)
122
+
123
+ # ---- wandb-like shape helpers (F9) ----
124
+ #
125
+ # SDK never imports wandb. These properties produce dicts shaped to
126
+ # be splatted into ``wandb.init(**...)`` / merged into
127
+ # ``wandb.log(...)``. User owns the wandb calls themselves.
128
+ #
129
+ # Kept inline (rather than delegated to a sibling module) so the
130
+ # only lazy import is the one ``wandb_like_metric`` needs for
131
+ # ``_report.latest_report_metrics``. No ``wandb`` import anywhere.
132
+
133
+ @property
134
+ def wandb_like_cfg(self) -> dict:
135
+ """Kwargs dict for ``wandb.init(**ctx.wandb_like_cfg)``. See F9/P6.
136
+
137
+ When ``RUNQ_SWEEP_KEYS`` is set (P6 W&B integration), the run
138
+ name is built from sweep parameter key=value pairs so each task
139
+ in a sweep is immediately distinguishable in the W&B UI.
140
+
141
+ When ``RUNQ_JOB_NOTE`` is set, it becomes the W&B group — giving
142
+ the user's note semantics as the grouping label instead of a raw
143
+ job id.
144
+
145
+ Shape::
146
+
147
+ {
148
+ "project": self.project or "runq",
149
+ "name": "lr=0.01_bs=32" or f"{job_id}/{task_id}",
150
+ "group": job_note or job_id or None,
151
+ "config": dict(self.params),
152
+ }
153
+
154
+ ``entity`` / ``mode`` are intentionally NOT included — those
155
+ come from the user's ``WANDB_*`` env / ``~/.netrc`` and runq
156
+ won't second-guess.
157
+ """
158
+ sweep_keys_raw = os.environ.get("RUNQ_SWEEP_KEYS", "")
159
+ sweep_keys = [k.strip() for k in sweep_keys_raw.split(",") if k.strip()] if sweep_keys_raw else []
160
+ job_note = os.environ.get("RUNQ_JOB_NOTE", "")
161
+
162
+ if sweep_keys and self.params:
163
+ parts = [f"{k}={self.params[k]}" for k in sweep_keys if k in self.params]
164
+ name = "_".join(parts) if parts else (f"{self.job_id}/{self.task_id}" if self.job_id else self.task_id)
165
+ else:
166
+ name = f"{self.job_id}/{self.task_id}" if self.job_id else self.task_id
167
+
168
+ group = job_note if job_note else (self.job_id or None)
169
+
170
+ return {
171
+ "project": self.project or "runq",
172
+ "name": name,
173
+ "group": group,
174
+ "config": dict(self.params or {}),
175
+ }
176
+
177
+ @property
178
+ def wandb_like_metric(self) -> dict:
179
+ """Latest reported metrics for ``wandb.log({**ctx.wandb_like_metric, ...})``.
180
+
181
+ Empty dict if no report has happened yet. Caller merges their
182
+ own extras at the call site::
183
+
184
+ wandb.log({**ctx.wandb_like_metric, "lr": lr}, step=epoch)
185
+
186
+ The returned dict is a copy — runq's internal history is not
187
+ exposed to mutation.
188
+ """
189
+ # Lazy import to avoid context ↔ report cycle (_report imports
190
+ # from _context at module load).
191
+ from . import _report
192
+ return _report.latest_report_metrics()
193
+
194
+
195
+ # ---- mode detection ----
196
+
197
+ def _truthy(s: str | None) -> bool:
198
+ """Lenient truthy parse for env vars. Empty / "0" / "false" → False."""
199
+ if not s:
200
+ return False
201
+ return s.strip().lower() not in ("0", "false", "no", "off")
202
+
203
+
204
+ def _detect_mode() -> str:
205
+ """Resolve which mode the SDK is operating in.
206
+
207
+ Order matters:
208
+ 1. No RUNQ_TASK_ID → user is running outside runq → manual.
209
+ 2. RUNQ_NO_DAEMON=1 (explicit HPC opt-out) → no_daemon.
210
+ 3. No socket path → daemon isn't reachable → no_daemon.
211
+ 4. Otherwise daemon.
212
+ """
213
+ if not os.environ.get("RUNQ_TASK_ID"):
214
+ return "manual"
215
+ if _truthy(os.environ.get("RUNQ_NO_DAEMON")):
216
+ return "no_daemon"
217
+ if not os.environ.get("RUNQ_SOCKET_PATH"):
218
+ return "no_daemon"
219
+ return "daemon"
220
+
221
+
222
+ def _load_params(path: Path | None) -> dict:
223
+ """Strict params.json loader. Missing file → empty dict; broken file
224
+ → RuntimeError. We surface load failures because silent {} would let
225
+ the user run with the wrong hyperparameters and only notice hours later.
226
+ """
227
+ if path is None or not path.exists():
228
+ return {}
229
+ try:
230
+ with open(path, encoding="utf-8") as f:
231
+ data = json.load(f)
232
+ except (OSError, json.JSONDecodeError) as e:
233
+ raise RuntimeError(f"runq: failed to load params from {path}: {e}") from e
234
+ if not isinstance(data, dict):
235
+ # Out of the try block on purpose — this is a structural error,
236
+ # not a parse failure, and we want the message to be specific.
237
+ raise RuntimeError(
238
+ f"runq: params file {path} must contain a JSON object, "
239
+ f"got {type(data).__name__}"
240
+ )
241
+ return data
242
+
243
+
244
+ def _int_env(name: str, default: int) -> int:
245
+ """Parse an int env var with a default. Returns default on parse error."""
246
+ raw = os.environ.get(name)
247
+ if raw is None or raw == "":
248
+ return default
249
+ try:
250
+ return int(raw)
251
+ except ValueError:
252
+ return default
253
+
254
+
255
+ def context() -> Context:
256
+ """Initialize the runq SDK context.
257
+
258
+ Idempotent: calling twice returns the same singleton. Reads
259
+ ``RUNQ_*`` env vars, loads ``params.json``, sets up the
260
+ ``metrics.jsonl`` sink, and stores the singleton for other SDK
261
+ modules to consume.
262
+
263
+ Returns the :class:`Context`. Lazy users usually do not need to keep
264
+ the return value because module-level helpers (``runq.log_metric``,
265
+ ``runq.report``) read the singleton themselves.
266
+
267
+ Modes:
268
+
269
+ * ``daemon`` — talks to a runq daemon over a unix socket.
270
+ * ``no_daemon`` — HPC mode; daemon not reachable. Writes jsonl
271
+ locally, ``safe_save`` will raise ``RunqDiskFullError`` on disk
272
+ pressure instead of freezing.
273
+ * ``manual`` — user is running their script directly without runq.
274
+ jsonl goes to ``./runq_metrics.jsonl``, task IDs are local UUIDs,
275
+ no daemon comm. Useful for IDE debugging.
276
+ """
277
+ global _ctx
278
+ if _ctx is not None:
279
+ return _ctx
280
+
281
+ mode = _detect_mode()
282
+ env = os.environ
283
+
284
+ if mode == "manual":
285
+ # Local mode: generate a short id, write jsonl into cwd, look for
286
+ # a sibling params.json (optional — most local debug runs have
287
+ # nothing).
288
+ task_id = "manual-" + uuid.uuid4().hex[:8]
289
+ task_dir: Path | None = None
290
+ checkpoint_dir: Path | None = None
291
+ metrics_file: Path | None = Path.cwd() / "runq_metrics.jsonl"
292
+ results_file: Path | None = Path.cwd() / "runq_results.jsonl"
293
+ events_file: Path | None = Path.cwd() / "runq_events.jsonl"
294
+ local_params = Path.cwd() / "params.json"
295
+ params = _load_params(local_params) if local_params.exists() else {}
296
+ else:
297
+ # daemon / no_daemon: daemon injects all the RUNQ_* vars.
298
+ task_id = env["RUNQ_TASK_ID"]
299
+ task_dir = Path(env["RUNQ_TASK_DIR"]) if env.get("RUNQ_TASK_DIR") else None
300
+ checkpoint_dir = (
301
+ Path(env["RUNQ_CHECKPOINT_DIR"]) if env.get("RUNQ_CHECKPOINT_DIR") else None
302
+ )
303
+ metrics_file = (
304
+ Path(env["RUNQ_METRICS_FILE"]) if env.get("RUNQ_METRICS_FILE") else None
305
+ )
306
+ # Siblings of metrics.jsonl (see Context field comment). The daemon's
307
+ # reap reads the same fixed names via workspace.ResultsPath/EventsPath.
308
+ results_file = metrics_file.with_name("results.jsonl") if metrics_file else None
309
+ events_file = metrics_file.with_name("events.jsonl") if metrics_file else None
310
+ params_file = env.get("RUNQ_PARAMS_FILE")
311
+ params = _load_params(Path(params_file)) if params_file else {}
312
+
313
+ _ctx = Context(
314
+ mode=mode,
315
+ task_id=task_id,
316
+ job_id=env.get("RUNQ_JOB_ID", ""),
317
+ project=env.get("RUNQ_PROJECT_NAME", ""),
318
+ task_dir=task_dir,
319
+ checkpoint_dir=checkpoint_dir,
320
+ metrics_file=metrics_file,
321
+ results_file=results_file,
322
+ events_file=events_file,
323
+ socket_path=env.get("RUNQ_SOCKET_PATH"),
324
+ safety_factor_percent=_int_env("RUNQ_SAFETY_FACTOR_PERCENT", 110),
325
+ safety_extra_gb=_int_env("RUNQ_SAFETY_EXTRA_GB", 0),
326
+ params=ParamDict(params),
327
+ seed=int(hashlib.sha256(task_id.encode()).hexdigest(), 16) % (2**32),
328
+ )
329
+
330
+ # Make sure dirs we will write to actually exist. Cheap, idempotent,
331
+ # and prevents a confusing first-write failure mid-training.
332
+ if _ctx.metrics_file is not None:
333
+ _ctx.metrics_file.parent.mkdir(parents=True, exist_ok=True)
334
+ if _ctx.checkpoint_dir is not None:
335
+ _ctx.checkpoint_dir.mkdir(parents=True, exist_ok=True)
336
+
337
+ # Step 7: if the user's job config carried an ``early_stopping:``
338
+ # block, build the matching built-in policy and register it.
339
+ # Done LAST so all ctx fields are populated by the time the policy
340
+ # factories run.
341
+ # Local import to keep _context.py free of a circular dep with
342
+ # _policies → _report → _context.
343
+ from . import _policies
344
+ _policies.maybe_register_from_params(_ctx)
345
+
346
+ return _ctx
347
+
348
+
349
+ def get_ctx() -> Context:
350
+ """Return the global context. Raises if ``context()`` was not called.
351
+
352
+ Mostly internal — user code usually keeps the return value of
353
+ ``context()`` directly. Exposed for SDK helpers that don't have a
354
+ handle on it.
355
+ """
356
+ if _ctx is None:
357
+ raise RuntimeError(
358
+ "runq: context() must be called before using other runq APIs. "
359
+ "Add `runq.context()` near the top of your script."
360
+ )
361
+ return _ctx
362
+
363
+
364
+ # ---- testing helper ----
365
+ #
366
+ # Not part of the public API but useful in tests to reset the singleton
367
+ # between test cases. Marked with underscore to discourage user calls.
368
+ def _reset_for_tests() -> None:
369
+ global _ctx
370
+ _ctx = None