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.
Files changed (43) hide show
  1. runq_sdk-0.5.0/PKG-INFO +69 -0
  2. runq_sdk-0.5.0/README.md +54 -0
  3. runq_sdk-0.5.0/pyproject.toml +90 -0
  4. runq_sdk-0.5.0/runq/__init__.py +90 -0
  5. runq_sdk-0.5.0/runq/_config.py +152 -0
  6. runq_sdk-0.5.0/runq/_context.py +370 -0
  7. runq_sdk-0.5.0/runq/_events.py +145 -0
  8. runq_sdk-0.5.0/runq/_exceptions.py +66 -0
  9. runq_sdk-0.5.0/runq/_loop.py +233 -0
  10. runq_sdk-0.5.0/runq/_manifest.py +426 -0
  11. runq_sdk-0.5.0/runq/_policies.py +281 -0
  12. runq_sdk-0.5.0/runq/_prefix.py +59 -0
  13. runq_sdk-0.5.0/runq/_range.py +149 -0
  14. runq_sdk-0.5.0/runq/_record.py +147 -0
  15. runq_sdk-0.5.0/runq/_report.py +307 -0
  16. runq_sdk-0.5.0/runq/_safe_save.py +551 -0
  17. runq_sdk-0.5.0/runq/_sizing.py +103 -0
  18. runq_sdk-0.5.0/runq/_transport.py +112 -0
  19. runq_sdk-0.5.0/runq/utils.py +78 -0
  20. runq_sdk-0.5.0/runq_sdk.egg-info/PKG-INFO +69 -0
  21. runq_sdk-0.5.0/runq_sdk.egg-info/SOURCES.txt +41 -0
  22. runq_sdk-0.5.0/runq_sdk.egg-info/dependency_links.txt +1 -0
  23. runq_sdk-0.5.0/runq_sdk.egg-info/requires.txt +8 -0
  24. runq_sdk-0.5.0/runq_sdk.egg-info/top_level.txt +1 -0
  25. runq_sdk-0.5.0/setup.cfg +4 -0
  26. runq_sdk-0.5.0/tests/test_context.py +176 -0
  27. runq_sdk-0.5.0/tests/test_early_stop.py +215 -0
  28. runq_sdk-0.5.0/tests/test_epoch.py +1 -0
  29. runq_sdk-0.5.0/tests/test_examples.py +155 -0
  30. runq_sdk-0.5.0/tests/test_log_group.py +203 -0
  31. runq_sdk-0.5.0/tests/test_log_metric.py +115 -0
  32. runq_sdk-0.5.0/tests/test_loop.py +165 -0
  33. runq_sdk-0.5.0/tests/test_manifest.py +365 -0
  34. runq_sdk-0.5.0/tests/test_policies.py +333 -0
  35. runq_sdk-0.5.0/tests/test_record.py +246 -0
  36. runq_sdk-0.5.0/tests/test_report.py +185 -0
  37. runq_sdk-0.5.0/tests/test_safe_save.py +337 -0
  38. runq_sdk-0.5.0/tests/test_safe_save_cleanup.py +357 -0
  39. runq_sdk-0.5.0/tests/test_safe_save_decorator.py +233 -0
  40. runq_sdk-0.5.0/tests/test_sizing.py +111 -0
  41. runq_sdk-0.5.0/tests/test_transport.py +220 -0
  42. runq_sdk-0.5.0/tests/test_utils.py +80 -0
  43. runq_sdk-0.5.0/tests/test_wandb_helpers.py +139 -0
@@ -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)
@@ -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})