resontech 0.1.1__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.
resontech/__init__.py ADDED
@@ -0,0 +1,98 @@
1
+ """
2
+ ResonTech SDK — federated learning job submission for the ResonTech platform.
3
+
4
+ The SDK's job is to:
5
+
6
+ 1. Authenticate with the REST API.
7
+ 2. Render every NVFlare artefact (configs + scripts + model_def.py) in memory.
8
+ 3. Upload shards + scripts + configs directly to the user's Garage S3 bucket.
9
+ 4. Submit the task (``POST /api/tasks``).
10
+ 5. Return a ``Job`` handle that points at the web dashboard.
11
+
12
+ Post-submit tracking (logs, progress, output files, model download) lives on
13
+ the web UI. Open ``job.dashboard_url`` once submission returns.
14
+
15
+ Quick start::
16
+
17
+ import torch.nn as nn
18
+ from torchvision import models
19
+ from resontech import ResonTech, ResonTechConfig, TrainingConfig, FederationConfig
20
+
21
+ class MyResNet(nn.Module):
22
+ def __init__(self, num_classes: int):
23
+ super().__init__()
24
+ self.backbone = models.resnet18(weights=None)
25
+ self.backbone.fc = nn.Linear(self.backbone.fc.in_features, num_classes)
26
+ def forward(self, x):
27
+ return self.backbone(x)
28
+
29
+ config = ResonTechConfig(
30
+ base_url="https://api.reson.tech",
31
+ email="you@example.com",
32
+ password="your-password",
33
+ s3_access_key_id="AKIA...",
34
+ s3_secret_access_key="...",
35
+ )
36
+
37
+ sdk = ResonTech(config)
38
+ sdk.login()
39
+ job = sdk.rt_submit(
40
+ model=MyResNet,
41
+ name="my-fl-job",
42
+ shards_dir="./shards",
43
+ training=TrainingConfig(num_classes=10, local_epochs=2),
44
+ federation=FederationConfig(num_rounds=5),
45
+ )
46
+ print(job.dashboard_url)
47
+ """
48
+
49
+ from ._version import __version__
50
+ from .client import ResonTech
51
+ from .config import ResonTechConfig
52
+ from .rt import RTJobBuilder, FederationConfig, ModelConfig, TrainingConfig
53
+ from .exceptions import (
54
+ AuthError,
55
+ ResonTechError,
56
+ NotFoundError,
57
+ ServerError,
58
+ ValidationError,
59
+ StorageError,
60
+ WorkspaceError,
61
+ )
62
+ from .models.job import Job, JobState
63
+ from .models.progress import TrainingProgress
64
+ from .models.requests import JobSubmitRequest
65
+ from .models.user import User
66
+ from .models.worker import WorkerStats, WorkerSummary
67
+ from .models.workspace import WorkspacePaths
68
+
69
+ __all__ = [
70
+ # Core
71
+ "ResonTech",
72
+ "ResonTechConfig",
73
+ # Request / submission
74
+ "JobSubmitRequest",
75
+ # Models / return types
76
+ "Job",
77
+ "JobState",
78
+ "User",
79
+ "WorkspacePaths",
80
+ "TrainingProgress",
81
+ "WorkerStats",
82
+ "WorkerSummary",
83
+ # Exceptions
84
+ "ResonTechError",
85
+ "AuthError",
86
+ "NotFoundError",
87
+ "ValidationError",
88
+ "StorageError",
89
+ "WorkspaceError",
90
+ "ServerError",
91
+ # RT job building
92
+ "RTJobBuilder",
93
+ "ModelConfig",
94
+ "TrainingConfig",
95
+ "FederationConfig",
96
+ # Meta
97
+ "__version__",
98
+ ]
resontech/_http.py ADDED
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+
5
+ from .exceptions import (
6
+ AuthError,
7
+ NotFoundError,
8
+ ResonTechError,
9
+ ServerError,
10
+ ValidationError,
11
+ )
12
+
13
+
14
+ class HttpClient:
15
+ """
16
+ Thin httpx wrapper for the ResonTech REST API.
17
+
18
+ - Persists cookies across requests (httpx.Client cookie jar).
19
+ - Sends a Bearer token in ``Authorization`` once ``set_access_token`` is called.
20
+ - On 401 attempts one silent refresh via ``/api/auth/refresh`` before
21
+ surfacing :class:`AuthError`.
22
+ """
23
+
24
+ def __init__(self, base_url: str, timeout: float = 60.0) -> None:
25
+ self._base_url = base_url.rstrip("/")
26
+ self._client = httpx.Client(
27
+ base_url=self._base_url,
28
+ follow_redirects=True,
29
+ timeout=timeout,
30
+ )
31
+ self._authed = False
32
+
33
+ # ── Auth ──────────────────────────────────────────────────────────────────
34
+
35
+ def set_access_token(self, token: str) -> None:
36
+ self._client.headers["Authorization"] = f"Bearer {token}"
37
+ self._authed = True
38
+
39
+ # ── Public request methods ────────────────────────────────────────────────
40
+
41
+ def get(self, path: str, **kwargs) -> dict | list:
42
+ return self._request("get", path, **kwargs)
43
+
44
+ def post(self, path: str, json: dict | None = None, **kwargs) -> dict:
45
+ return self._request("post", path, json=json, **kwargs)
46
+
47
+ # ── Internals ─────────────────────────────────────────────────────────────
48
+
49
+ def _request(self, method: str, path: str, **kwargs) -> dict | list:
50
+ try:
51
+ resp = getattr(self._client, method)(path, **kwargs)
52
+ except httpx.ConnectError as exc:
53
+ raise ResonTechError(
54
+ f"Cannot reach {self._base_url} — is the backend running?"
55
+ ) from exc
56
+
57
+ # One silent refresh on 401, then surface AuthError if it still fails.
58
+ if resp.status_code == 401 and self._authed and self._try_refresh():
59
+ resp = getattr(self._client, method)(path, **kwargs)
60
+
61
+ self._raise_for_status(resp)
62
+ # Tolerate 204/empty bodies — some endpoints (e.g. upload/complete)
63
+ # return no content on success.
64
+ if resp.status_code == 204 or not resp.content:
65
+ return {}
66
+ try:
67
+ return resp.json()
68
+ except ValueError:
69
+ return {}
70
+
71
+ def _try_refresh(self) -> bool:
72
+ """POST /api/auth/refresh using the refresh_token cookie. Returns True on success."""
73
+ try:
74
+ r = self._client.post("/api/auth/refresh")
75
+ except Exception:
76
+ return False
77
+ if r.status_code not in (200, 201):
78
+ return False
79
+ token = r.json().get("accessToken")
80
+ if not token:
81
+ return False
82
+ self.set_access_token(token)
83
+ return True
84
+
85
+ @staticmethod
86
+ def _raise_for_status(resp: httpx.Response) -> None:
87
+ code = resp.status_code
88
+ if code < 400:
89
+ return
90
+ # Prefer the JSON "message" field, fall back to the raw body.
91
+ try:
92
+ msg = resp.json().get("message", resp.text)
93
+ except Exception:
94
+ msg = resp.text or ""
95
+
96
+ if code == 400:
97
+ raise ValidationError(str(msg or "Validation error"))
98
+ if code == 401:
99
+ raise AuthError(f"401 Unauthorized: {msg or 'Invalid credentials or session expired.'}")
100
+ if code == 403:
101
+ raise AuthError(f"Forbidden: {msg or resp.text}")
102
+ if code == 404:
103
+ raise NotFoundError(f"Resource not found: {resp.url}")
104
+ if code >= 500:
105
+ raise ServerError(f"Server error {code}: {str(msg)[:200]}")
106
+ raise ResonTechError(f"HTTP {code}: {str(msg)[:200]}")
107
+
108
+ # ── Lifecycle ─────────────────────────────────────────────────────────────
109
+
110
+ def close(self) -> None:
111
+ self._client.close()
112
+
113
+ def __enter__(self) -> "HttpClient":
114
+ return self
115
+
116
+ def __exit__(self, *_: object) -> None:
117
+ self.close()
resontech/_s3.py ADDED
@@ -0,0 +1,252 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ import threading
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import boto3
10
+ from boto3.s3.transfer import TransferConfig
11
+ from botocore.config import Config as BotoConfig
12
+ from botocore.exceptions import BotoCoreError, ClientError
13
+
14
+ from .exceptions import StorageError
15
+
16
+
17
+ # Mirrors the web UI's upload constants (STORAGE.md):
18
+ # files < 10 MB → single PutObject
19
+ # files ≥ 10 MB → multipart, 50 MB parts, 5 concurrent parts
20
+ _MULTIPART_THRESHOLD = 10 * 1024 * 1024
21
+ _MULTIPART_CHUNKSIZE = 50 * 1024 * 1024
22
+ _MAX_CONCURRENCY = 2 # was 5 — reduced for Garage stability
23
+
24
+
25
+ class S3Client:
26
+ """
27
+ Thin boto3 wrapper pointed at the user's Garage bucket.
28
+
29
+ The underlying ``boto3`` client does the heavy lifting — multipart uploads
30
+ and concurrency are handled by the built-in TransferManager. This wrapper
31
+ adds: consistent error wrapping into :class:`StorageError`, byte/file
32
+ convenience methods, and a tiny terminal progress printer.
33
+
34
+ Escape hatch: the raw client is available as ``S3Client.boto3`` if you
35
+ need an API this wrapper does not expose (e.g. copy, delete, sync).
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ endpoint: str,
42
+ region: str,
43
+ access_key_id: str,
44
+ secret_access_key: str,
45
+ bucket: str,
46
+ ) -> None:
47
+ if not access_key_id or not secret_access_key:
48
+ raise StorageError(
49
+ "s3_access_key_id and s3_secret_access_key are required. "
50
+ "Provision a bucket in the web UI (Profile → Storage) to get them."
51
+ )
52
+ self._bucket = bucket
53
+ self._transfer = TransferConfig(
54
+ multipart_threshold=_MULTIPART_THRESHOLD,
55
+ multipart_chunksize=_MULTIPART_CHUNKSIZE,
56
+ max_concurrency=_MAX_CONCURRENCY,
57
+ use_threads=True,
58
+ )
59
+ self._client = boto3.client(
60
+ "s3",
61
+ endpoint_url=endpoint,
62
+ region_name=region,
63
+ aws_access_key_id=access_key_id,
64
+ aws_secret_access_key=secret_access_key,
65
+ config=BotoConfig(
66
+ signature_version="s3v4",
67
+ s3={"addressing_style": "path"},
68
+ retries={"max_attempts": 5, "mode": "standard"},
69
+ ),
70
+ )
71
+
72
+ # ── Accessors ─────────────────────────────────────────────────────────────
73
+
74
+ @property
75
+ def bucket(self) -> str:
76
+ return self._bucket
77
+
78
+ @property
79
+ def boto3(self): # type: ignore[override]
80
+ """Escape hatch — the raw ``boto3`` S3 client. Use for ops this wrapper skips."""
81
+ return self._client
82
+
83
+ def set_bucket(self, bucket: str) -> None:
84
+ self._bucket = bucket
85
+
86
+ # ── Writes ────────────────────────────────────────────────────────────────
87
+
88
+ def put_bytes(self, key: str, body: bytes | str, content_type: str = "application/octet-stream") -> None:
89
+ """Write a small payload (configs, scripts) to S3."""
90
+ data = body.encode("utf-8") if isinstance(body, str) else body
91
+ try:
92
+ self._client.put_object(
93
+ Bucket=self._bucket,
94
+ Key=key,
95
+ Body=data,
96
+ ContentType=content_type,
97
+ )
98
+ except (BotoCoreError, ClientError) as exc:
99
+ raise StorageError(f"Failed to upload {key}: {exc}") from exc
100
+
101
+ def upload_file(
102
+ self,
103
+ local_path: str | os.PathLike,
104
+ key: str,
105
+ content_type: str = "application/octet-stream",
106
+ show_progress: bool = True,
107
+ ) -> None:
108
+ """
109
+ Upload a local file to S3. boto3 auto-switches to multipart for files
110
+ larger than :data:`_MULTIPART_THRESHOLD` (10 MB by default).
111
+
112
+ On any failure (network error or KeyboardInterrupt), best-effort aborts
113
+ any multipart upload that boto3 left active for this key — Garage and
114
+ many other S3 implementations don't auto-GC orphans, and a stale
115
+ multipart will block the next CreateMultipartUpload on the same key.
116
+ """
117
+ path = Path(local_path)
118
+ if not path.is_file():
119
+ raise StorageError(f"File not found: {path}")
120
+ size = path.stat().st_size
121
+ cb = _ProgressPrinter(path.name, size) if show_progress else None
122
+ try:
123
+ self._client.upload_file(
124
+ Filename=str(path),
125
+ Bucket=self._bucket,
126
+ Key=key,
127
+ Config=self._transfer,
128
+ ExtraArgs={"ContentType": content_type},
129
+ Callback=cb,
130
+ )
131
+ except (BotoCoreError, ClientError) as exc:
132
+ self._abort_multiparts_for_key(key)
133
+ raise StorageError(f"Failed to upload {path.name} → {key}: {exc}") from exc
134
+ except (KeyboardInterrupt, SystemExit):
135
+ self._abort_multiparts_for_key(key)
136
+ raise
137
+ if cb is not None:
138
+ cb.finish()
139
+
140
+ # ── Multipart cleanup ─────────────────────────────────────────────────────
141
+
142
+ def list_multipart_uploads(self, prefix: str = "") -> list[dict]:
143
+ """List active multipart uploads under ``prefix``."""
144
+ try:
145
+ resp = self._client.list_multipart_uploads(Bucket=self._bucket, Prefix=prefix)
146
+ except (BotoCoreError, ClientError) as exc:
147
+ raise StorageError(f"list_multipart_uploads({prefix!r}) failed: {exc}") from exc
148
+ return resp.get("Uploads", []) or []
149
+
150
+ def abort_multipart_uploads(self, prefix: str = "") -> int:
151
+ """
152
+ Abort every active multipart upload under ``prefix``. Returns the
153
+ number aborted. Stale multiparts left over from prior failed runs
154
+ block CreateMultipartUpload on the same key on Garage; sweeping at
155
+ submission start is cheap and removes a class of "stuck upload" bugs.
156
+ """
157
+ uploads = self.list_multipart_uploads(prefix)
158
+ n = 0
159
+ for u in uploads:
160
+ try:
161
+ self._client.abort_multipart_upload(
162
+ Bucket=self._bucket, Key=u["Key"], UploadId=u["UploadId"]
163
+ )
164
+ n += 1
165
+ except (BotoCoreError, ClientError):
166
+ pass # best-effort
167
+ return n
168
+
169
+ def _abort_multiparts_for_key(self, key: str) -> None:
170
+ """Best-effort abort of any in-flight multiparts for a single key."""
171
+ try:
172
+ for u in self.list_multipart_uploads(prefix=key):
173
+ if u["Key"] != key:
174
+ continue
175
+ try:
176
+ self._client.abort_multipart_upload(
177
+ Bucket=self._bucket, Key=key, UploadId=u["UploadId"]
178
+ )
179
+ except (BotoCoreError, ClientError):
180
+ pass
181
+ except StorageError:
182
+ pass
183
+
184
+ # ── Reads / metadata ──────────────────────────────────────────────────────
185
+
186
+ def head(self, key: str) -> Optional[dict]:
187
+ """Return object metadata, or ``None`` if the key does not exist."""
188
+ try:
189
+ return self._client.head_object(Bucket=self._bucket, Key=key)
190
+ except ClientError as exc:
191
+ code = exc.response.get("Error", {}).get("Code", "")
192
+ if code in ("404", "NoSuchKey", "NotFound"):
193
+ return None
194
+ raise StorageError(f"head_object({key}) failed: {exc}") from exc
195
+
196
+ def list(self, prefix: str) -> list[str]:
197
+ """List object keys under a prefix (flat, no delimiter)."""
198
+ try:
199
+ resp = self._client.list_objects_v2(Bucket=self._bucket, Prefix=prefix)
200
+ except (BotoCoreError, ClientError) as exc:
201
+ raise StorageError(f"list({prefix}) failed: {exc}") from exc
202
+ return [item["Key"] for item in resp.get("Contents", [])]
203
+
204
+ def presign_get(self, key: str, expires_in: int = 3600) -> str:
205
+ """Generate a presigned download URL. Default TTL: 1 hour."""
206
+ try:
207
+ return self._client.generate_presigned_url(
208
+ "get_object",
209
+ Params={"Bucket": self._bucket, "Key": key},
210
+ ExpiresIn=expires_in,
211
+ )
212
+ except (BotoCoreError, ClientError) as exc:
213
+ raise StorageError(f"presign_get({key}) failed: {exc}") from exc
214
+
215
+
216
+ class _ProgressPrinter:
217
+ """
218
+ Tiny stderr progress printer for CLI uploads. boto3 calls this from worker
219
+ threads, so the counter update is lock-guarded.
220
+
221
+ Notebook note: the ``\\r`` carriage return works in terminals and the
222
+ classic Jupyter console; some IDE renderers leave a trail (one line per
223
+ percent). This is cosmetic.
224
+ """
225
+
226
+ def __init__(self, name: str, total: int) -> None:
227
+ self._name = name
228
+ self._total = max(total, 1)
229
+ self._size_mb = total / (1024 * 1024)
230
+ self._seen = 0
231
+ self._lock = threading.Lock()
232
+ self._last_pct_step = -1
233
+
234
+ def __call__(self, bytes_amount: int) -> None:
235
+ with self._lock:
236
+ self._seen += bytes_amount
237
+ pct = int(self._seen * 100 / self._total)
238
+ # Emit a new line every 5 % so Jupyter renderers (which coalesce
239
+ # \r-only updates) still produce visible progress per shard.
240
+ step = pct // 5
241
+ if step != self._last_pct_step:
242
+ self._last_pct_step = step
243
+ seen_mb = self._seen / (1024 * 1024)
244
+ sys.stderr.write(
245
+ f"[resontech] {self._name}: {pct:3d}% "
246
+ f"({seen_mb:7.1f} / {self._size_mb:7.1f} MB)\n"
247
+ )
248
+ sys.stderr.flush()
249
+
250
+ def finish(self) -> None:
251
+ sys.stderr.write(f"[resontech] {self._name}: done\n")
252
+ sys.stderr.flush()
resontech/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"