runq-sdk 0.5.0__tar.gz
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_sdk-0.5.0/PKG-INFO +69 -0
- runq_sdk-0.5.0/README.md +54 -0
- runq_sdk-0.5.0/pyproject.toml +90 -0
- runq_sdk-0.5.0/runq/__init__.py +90 -0
- runq_sdk-0.5.0/runq/_config.py +152 -0
- runq_sdk-0.5.0/runq/_context.py +370 -0
- runq_sdk-0.5.0/runq/_events.py +145 -0
- runq_sdk-0.5.0/runq/_exceptions.py +66 -0
- runq_sdk-0.5.0/runq/_loop.py +233 -0
- runq_sdk-0.5.0/runq/_manifest.py +426 -0
- runq_sdk-0.5.0/runq/_policies.py +281 -0
- runq_sdk-0.5.0/runq/_prefix.py +59 -0
- runq_sdk-0.5.0/runq/_range.py +149 -0
- runq_sdk-0.5.0/runq/_record.py +147 -0
- runq_sdk-0.5.0/runq/_report.py +307 -0
- runq_sdk-0.5.0/runq/_safe_save.py +551 -0
- runq_sdk-0.5.0/runq/_sizing.py +103 -0
- runq_sdk-0.5.0/runq/_transport.py +112 -0
- runq_sdk-0.5.0/runq/utils.py +78 -0
- runq_sdk-0.5.0/runq_sdk.egg-info/PKG-INFO +69 -0
- runq_sdk-0.5.0/runq_sdk.egg-info/SOURCES.txt +41 -0
- runq_sdk-0.5.0/runq_sdk.egg-info/dependency_links.txt +1 -0
- runq_sdk-0.5.0/runq_sdk.egg-info/requires.txt +8 -0
- runq_sdk-0.5.0/runq_sdk.egg-info/top_level.txt +1 -0
- runq_sdk-0.5.0/setup.cfg +4 -0
- runq_sdk-0.5.0/tests/test_context.py +176 -0
- runq_sdk-0.5.0/tests/test_early_stop.py +215 -0
- runq_sdk-0.5.0/tests/test_epoch.py +1 -0
- runq_sdk-0.5.0/tests/test_examples.py +155 -0
- runq_sdk-0.5.0/tests/test_log_group.py +203 -0
- runq_sdk-0.5.0/tests/test_log_metric.py +115 -0
- runq_sdk-0.5.0/tests/test_loop.py +165 -0
- runq_sdk-0.5.0/tests/test_manifest.py +365 -0
- runq_sdk-0.5.0/tests/test_policies.py +333 -0
- runq_sdk-0.5.0/tests/test_record.py +246 -0
- runq_sdk-0.5.0/tests/test_report.py +185 -0
- runq_sdk-0.5.0/tests/test_safe_save.py +337 -0
- runq_sdk-0.5.0/tests/test_safe_save_cleanup.py +357 -0
- runq_sdk-0.5.0/tests/test_safe_save_decorator.py +233 -0
- runq_sdk-0.5.0/tests/test_sizing.py +111 -0
- runq_sdk-0.5.0/tests/test_transport.py +220 -0
- runq_sdk-0.5.0/tests/test_utils.py +80 -0
- runq_sdk-0.5.0/tests/test_wandb_helpers.py +139 -0
runq_sdk-0.5.0/PKG-INFO
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runq-sdk
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Lab GPU scheduler — in-task Python SDK
|
|
5
|
+
Author: runq contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: httpx>=0.24
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
12
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
13
|
+
Provides-Extra: wandb
|
|
14
|
+
Requires-Dist: wandb>=0.15; extra == "wandb"
|
|
15
|
+
|
|
16
|
+
# runq Python SDK
|
|
17
|
+
|
|
18
|
+
In-task SDK for the [runq](../../README.md) GPU scheduler.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
From the repo root:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pip install -e ./sdk/python
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Optional extras:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
pip install -e "./sdk/python[wandb]" # auto wandb integration
|
|
32
|
+
pip install -e "./sdk/python[dev]" # for pytest
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import runq
|
|
39
|
+
|
|
40
|
+
ctx = runq.context()
|
|
41
|
+
runq.log_metric("loss", 0.42, step=epoch)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
See `demo/l2c/stage2_sdk_design.md` for the full API design.
|
|
45
|
+
|
|
46
|
+
## Three modes
|
|
47
|
+
|
|
48
|
+
Detected automatically at `runq.context()`:
|
|
49
|
+
|
|
50
|
+
- `daemon` — running under a runq daemon (`RUNQ_TASK_ID` + `RUNQ_SOCKET_PATH` set)
|
|
51
|
+
- `no_daemon` — running on HPC compute node (`RUNQ_TASK_ID` set, no socket / `RUNQ_NO_DAEMON=1`)
|
|
52
|
+
- `manual` — running outside runq (no `RUNQ_TASK_ID`); useful for local debugging
|
|
53
|
+
|
|
54
|
+
`ctx.mode` exposes which mode is active.
|
|
55
|
+
|
|
56
|
+
## Stage 2 status
|
|
57
|
+
|
|
58
|
+
This is incremental — Tier A first, Tier B/C as we go.
|
|
59
|
+
|
|
60
|
+
- [x] **Step 1**: package skeleton + `context()` + tri-mode + `log_metric`
|
|
61
|
+
- [ ] Step 2: `transport.py` (unix socket HTTP client)
|
|
62
|
+
- [ ] Step 3: `safe_save` core (path resolution + TOCTOU + freeze)
|
|
63
|
+
- [ ] Step 4: `safe_save` decorator + size walker
|
|
64
|
+
- [ ] Step 5: `safe_save` manifest + keep_last_n / keep_best
|
|
65
|
+
- [ ] Step 6: `report` + `Decision` + `@early_stop`
|
|
66
|
+
- [ ] Step 7: YAML early_stop policies
|
|
67
|
+
- [ ] Step 8: `loop` + `@epoch` + `@log_group`
|
|
68
|
+
- [ ] Step 9: wandb integration (L3)
|
|
69
|
+
- [ ] Step 10: daemon-side preflight (Go side, parallel track)
|
runq_sdk-0.5.0/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# runq Python SDK
|
|
2
|
+
|
|
3
|
+
In-task SDK for the [runq](../../README.md) GPU scheduler.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
From the repo root:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pip install -e ./sdk/python
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Optional extras:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pip install -e "./sdk/python[wandb]" # auto wandb integration
|
|
17
|
+
pip install -e "./sdk/python[dev]" # for pytest
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import runq
|
|
24
|
+
|
|
25
|
+
ctx = runq.context()
|
|
26
|
+
runq.log_metric("loss", 0.42, step=epoch)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
See `demo/l2c/stage2_sdk_design.md` for the full API design.
|
|
30
|
+
|
|
31
|
+
## Three modes
|
|
32
|
+
|
|
33
|
+
Detected automatically at `runq.context()`:
|
|
34
|
+
|
|
35
|
+
- `daemon` — running under a runq daemon (`RUNQ_TASK_ID` + `RUNQ_SOCKET_PATH` set)
|
|
36
|
+
- `no_daemon` — running on HPC compute node (`RUNQ_TASK_ID` set, no socket / `RUNQ_NO_DAEMON=1`)
|
|
37
|
+
- `manual` — running outside runq (no `RUNQ_TASK_ID`); useful for local debugging
|
|
38
|
+
|
|
39
|
+
`ctx.mode` exposes which mode is active.
|
|
40
|
+
|
|
41
|
+
## Stage 2 status
|
|
42
|
+
|
|
43
|
+
This is incremental — Tier A first, Tier B/C as we go.
|
|
44
|
+
|
|
45
|
+
- [x] **Step 1**: package skeleton + `context()` + tri-mode + `log_metric`
|
|
46
|
+
- [ ] Step 2: `transport.py` (unix socket HTTP client)
|
|
47
|
+
- [ ] Step 3: `safe_save` core (path resolution + TOCTOU + freeze)
|
|
48
|
+
- [ ] Step 4: `safe_save` decorator + size walker
|
|
49
|
+
- [ ] Step 5: `safe_save` manifest + keep_last_n / keep_best
|
|
50
|
+
- [ ] Step 6: `report` + `Decision` + `@early_stop`
|
|
51
|
+
- [ ] Step 7: YAML early_stop policies
|
|
52
|
+
- [ ] Step 8: `loop` + `@epoch` + `@log_group`
|
|
53
|
+
- [ ] Step 9: wandb integration (L3)
|
|
54
|
+
- [ ] Step 10: daemon-side preflight (Go side, parallel track)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "runq-sdk"
|
|
3
|
+
version = "0.5.0"
|
|
4
|
+
description = "Lab GPU scheduler — in-task Python SDK"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
# 3.10 floor: matches PyTorch's current support window and is widely
|
|
7
|
+
# deployed in lab conda envs. 3.9 EOL is Oct 2025 — not worth carrying.
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
authors = [{name = "runq contributors"}]
|
|
11
|
+
|
|
12
|
+
# httpx for HTTP-over-unix-socket. Pulls in httpcore + h11 + anyio +
|
|
13
|
+
# idna + sniffio — about 1 MB total. Lab user envs almost always have
|
|
14
|
+
# these already (transitively via wandb / transformers / fastapi).
|
|
15
|
+
# Worth the dep for cleaner code than hand-rolled framing.
|
|
16
|
+
#
|
|
17
|
+
# torch is intentionally NOT pinned — safe_save's size estimator
|
|
18
|
+
# imports it lazily, missing torch falls back to size_hint requirement.
|
|
19
|
+
dependencies = [
|
|
20
|
+
"httpx>=0.24",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = [
|
|
25
|
+
"pytest>=7.0",
|
|
26
|
+
"ruff>=0.6",
|
|
27
|
+
]
|
|
28
|
+
# Convenience: pip install -e ".[wandb]" pulls wandb for users who want
|
|
29
|
+
# the L3 mirror integration. Core SDK still works without it.
|
|
30
|
+
wandb = [
|
|
31
|
+
"wandb>=0.15",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["setuptools>=61"]
|
|
36
|
+
build-backend = "setuptools.build_meta"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
where = ["."]
|
|
40
|
+
include = ["runq*"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
|
|
45
|
+
[tool.ruff]
|
|
46
|
+
# Match the package's Python floor. Older targets force a more
|
|
47
|
+
# conservative subset (e.g. no `X | Y` types, no `int | None` PEP 604).
|
|
48
|
+
target-version = "py310"
|
|
49
|
+
line-length = 100
|
|
50
|
+
|
|
51
|
+
# Source roots that ruff walks. ``examples/`` is included so demos
|
|
52
|
+
# follow the same hygiene as the SDK itself.
|
|
53
|
+
src = ["runq", "tests", "examples"]
|
|
54
|
+
|
|
55
|
+
# Built-in pyc caches + editor backups never need to be linted.
|
|
56
|
+
extend-exclude = ["__pycache__", "*.pyc", ".venv", "build", "dist"]
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint]
|
|
59
|
+
# Selected rule sets. Wider than pyflakes-only (E/F) but narrower than
|
|
60
|
+
# "all" — keeping bloat out and signal high. Rationale per group:
|
|
61
|
+
# E,W — pycodestyle (style errors / warnings; the baseline)
|
|
62
|
+
# F — pyflakes (real bugs: undefined names, unused imports)
|
|
63
|
+
# I — isort (consistent import order — small but constant value)
|
|
64
|
+
# B — flake8-bugbear (mutable defaults, except-pass, etc.)
|
|
65
|
+
# UP — pyupgrade (modernizations for the target Python)
|
|
66
|
+
# SIM — flake8-simplify (collapse-able patterns)
|
|
67
|
+
# RUF — ruff-native rules (RUF100 for unused noqa, etc.)
|
|
68
|
+
select = ["E", "W", "F", "I", "B", "UP", "SIM", "RUF"]
|
|
69
|
+
|
|
70
|
+
# Global ignores:
|
|
71
|
+
# - RUF002/RUF003: em-dashes (—) and other Unicode look-alikes in
|
|
72
|
+
# docstrings + comments. We use em-dashes intentionally throughout
|
|
73
|
+
# the SDK; the ASCII fallback ("--") would regress readability.
|
|
74
|
+
# - SIM105: rewrite ``try: ... except X: pass`` as
|
|
75
|
+
# ``with contextlib.suppress(X): ...``. The try/except/pass pattern
|
|
76
|
+
# is idiomatic and arguably clearer at the call site (exception
|
|
77
|
+
# class is right there, no extra import); collapse-to-suppress
|
|
78
|
+
# doesn't add signal.
|
|
79
|
+
ignore = ["RUF002", "RUF003", "SIM105"]
|
|
80
|
+
|
|
81
|
+
# Per-file relaxations:
|
|
82
|
+
# - tests can have unused imports (pytest plugins, fixtures defined in
|
|
83
|
+
# conftest), `assert` patterns, and longer lines for readability.
|
|
84
|
+
# - examples have prose-y comments that may push line length.
|
|
85
|
+
[tool.ruff.lint.per-file-ignores]
|
|
86
|
+
"tests/*" = ["E501", "F401", "B011"]
|
|
87
|
+
"examples/*" = ["E501"]
|
|
88
|
+
|
|
89
|
+
[tool.ruff.lint.isort]
|
|
90
|
+
known-first-party = ["runq"]
|
|
@@ -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}")
|
|
@@ -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})
|