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 +98 -0
- resontech/_http.py +117 -0
- resontech/_s3.py +252 -0
- resontech/_version.py +1 -0
- resontech/client.py +496 -0
- resontech/config.py +62 -0
- resontech/exceptions.py +26 -0
- resontech/models/__init__.py +17 -0
- resontech/models/job.py +60 -0
- resontech/models/progress.py +15 -0
- resontech/models/requests.py +51 -0
- resontech/models/user.py +31 -0
- resontech/models/worker.py +38 -0
- resontech/models/workspace.py +48 -0
- resontech/resources/__init__.py +4 -0
- resontech/resources/dashboard.py +78 -0
- resontech/resources/jobs.py +56 -0
- resontech/rt/__init__.py +24 -0
- resontech/rt/_platform/__init__.py +2 -0
- resontech/rt/_platform/custom_client_executor.py +320 -0
- resontech/rt/_platform/custom_persistor.py +44 -0
- resontech/rt/builder.py +311 -0
- resontech/rt/config.py +142 -0
- resontech-0.1.1.dist-info/METADATA +468 -0
- resontech-0.1.1.dist-info/RECORD +28 -0
- resontech-0.1.1.dist-info/WHEEL +5 -0
- resontech-0.1.1.dist-info/licenses/LICENSE +201 -0
- resontech-0.1.1.dist-info/top_level.txt +1 -0
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"
|