blobhub 0.2.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.
- blobhub/__init__.py +57 -0
- blobhub/_auth.py +83 -0
- blobhub/_blob.py +96 -0
- blobhub/_cache.py +83 -0
- blobhub/_client.py +126 -0
- blobhub/_credentials.py +78 -0
- blobhub/_operation.py +61 -0
- blobhub/_retry.py +52 -0
- blobhub/_revision.py +125 -0
- blobhub/_settings.py +55 -0
- blobhub/_transport.py +260 -0
- blobhub/_wait.py +35 -0
- blobhub/errors.py +163 -0
- blobhub/notebooks/__init__.py +84 -0
- blobhub/notebooks/__main__.py +42 -0
- blobhub/notebooks/onnx/bi_att_flow.ipynb +190 -0
- blobhub/notebooks/onnx/object_detection.ipynb +208 -0
- blobhub/notebooks/onnx/super_resolution.ipynb +184 -0
- blobhub/notebooks/onnx/super_resolution_end_to_end.ipynb +354 -0
- blobhub/types/__init__.py +29 -0
- blobhub/types/_base.py +27 -0
- blobhub/types/onnx/__init__.py +94 -0
- blobhub/types/onnx/_archive.py +26 -0
- blobhub/types/onnx/_transfer.py +116 -0
- blobhub/types/orientdb/__init__.py +78 -0
- blobhub/types/workflow/__init__.py +61 -0
- blobhub/types/workflow/_execution.py +146 -0
- blobhub/types/workflow/_reference.py +103 -0
- blobhub/types/workflow/_sessions.py +68 -0
- blobhub-0.2.0.dist-info/METADATA +220 -0
- blobhub-0.2.0.dist-info/RECORD +33 -0
- blobhub-0.2.0.dist-info/WHEEL +4 -0
- blobhub-0.2.0.dist-info/licenses/LICENSE.txt +21 -0
blobhub/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""BlobHub Python SDK: blobs, revisions and typed data access for the BlobHub platform."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.2.0"
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
|
|
7
|
+
from blobhub._blob import Blob
|
|
8
|
+
from blobhub._client import Client, Identity, connect
|
|
9
|
+
from blobhub._operation import Operation
|
|
10
|
+
from blobhub._revision import Revision
|
|
11
|
+
from blobhub.errors import (
|
|
12
|
+
AuthError,
|
|
13
|
+
BlobHubError,
|
|
14
|
+
CommandError,
|
|
15
|
+
ConfigError,
|
|
16
|
+
Conflict,
|
|
17
|
+
NetworkError,
|
|
18
|
+
NotFound,
|
|
19
|
+
OperationFailed,
|
|
20
|
+
PermissionDenied,
|
|
21
|
+
RateLimited,
|
|
22
|
+
RequestNotSent,
|
|
23
|
+
ServerError,
|
|
24
|
+
TypeMismatch,
|
|
25
|
+
WaitTimeout,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AuthError",
|
|
30
|
+
"Blob",
|
|
31
|
+
"BlobHubError",
|
|
32
|
+
"Client",
|
|
33
|
+
"CommandError",
|
|
34
|
+
"ConfigError",
|
|
35
|
+
"Conflict",
|
|
36
|
+
"Identity",
|
|
37
|
+
"NetworkError",
|
|
38
|
+
"NotFound",
|
|
39
|
+
"Operation",
|
|
40
|
+
"OperationFailed",
|
|
41
|
+
"PermissionDenied",
|
|
42
|
+
"RateLimited",
|
|
43
|
+
"RequestNotSent",
|
|
44
|
+
"Revision",
|
|
45
|
+
"ServerError",
|
|
46
|
+
"TypeMismatch",
|
|
47
|
+
"WaitTimeout",
|
|
48
|
+
"__version__",
|
|
49
|
+
"connect",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def __getattr__(name: str):
|
|
54
|
+
"""Resolve `blobhub.notebooks` lazily so `import blobhub` stays light."""
|
|
55
|
+
if name == "notebooks":
|
|
56
|
+
return importlib.import_module("blobhub.notebooks")
|
|
57
|
+
raise AttributeError(f"module 'blobhub' has no attribute {name!r}")
|
blobhub/_auth.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""How a request is authenticated: an API key, or an anonymous token pair held in memory."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
from blobhub.errors import AuthError, ServerError
|
|
7
|
+
|
|
8
|
+
REFRESH_EARLY_MS = 60_000
|
|
9
|
+
|
|
10
|
+
Fetch = Callable[[str, str, dict | None], dict]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class KeyAuth:
|
|
14
|
+
"""Sends the key as `X-API-Key`; a rejected key is final, never a reason to fall back to anonymous."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, api_key: str) -> None:
|
|
17
|
+
self._api_key = api_key
|
|
18
|
+
|
|
19
|
+
def __repr__(self) -> str:
|
|
20
|
+
return "KeyAuth()"
|
|
21
|
+
|
|
22
|
+
def headers(self) -> dict[str, str]:
|
|
23
|
+
"""The headers that authenticate one request."""
|
|
24
|
+
return {"X-API-Key": self._api_key}
|
|
25
|
+
|
|
26
|
+
def renew(self) -> bool:
|
|
27
|
+
"""A key cannot be renewed; returns False."""
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AnonymousAuth:
|
|
32
|
+
"""Mints an anonymous token pair on first use and renews it; both tokens live in memory only."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, fetch: Fetch, *, wall_clock: Callable[[], float] = time.time) -> None:
|
|
35
|
+
self._fetch = fetch
|
|
36
|
+
self._wall_clock = wall_clock
|
|
37
|
+
self._access: str | None = None
|
|
38
|
+
self._refresh: str | None = None
|
|
39
|
+
self._expires_at_ms: int | None = None
|
|
40
|
+
|
|
41
|
+
def __repr__(self) -> str:
|
|
42
|
+
return "AnonymousAuth()"
|
|
43
|
+
|
|
44
|
+
def headers(self) -> dict[str, str]:
|
|
45
|
+
"""A bearer header, minting the pair first or refreshing a token within a minute of `expires_at`."""
|
|
46
|
+
if self._access is None:
|
|
47
|
+
self._mint()
|
|
48
|
+
elif self._expires_at_ms is not None and self._wall_clock() * 1000 >= self._expires_at_ms - REFRESH_EARLY_MS:
|
|
49
|
+
self._renew()
|
|
50
|
+
return {"Authorization": f"Bearer {self._access}"}
|
|
51
|
+
|
|
52
|
+
def renew(self) -> bool:
|
|
53
|
+
"""Refresh the access token, or mint a new pair if the refresh is refused; returns True."""
|
|
54
|
+
self._renew()
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
def _mint(self) -> None:
|
|
58
|
+
body = self._fetch("GET", "auth/core/anonymous", None)
|
|
59
|
+
self._refresh = _token(body, "refresh_token")
|
|
60
|
+
self._store(body)
|
|
61
|
+
|
|
62
|
+
def _renew(self) -> None:
|
|
63
|
+
if self._refresh is None:
|
|
64
|
+
self._mint()
|
|
65
|
+
return
|
|
66
|
+
try:
|
|
67
|
+
body = self._fetch("POST", "auth/core/refresh", {"refresh_token": self._refresh})
|
|
68
|
+
except AuthError:
|
|
69
|
+
self._mint()
|
|
70
|
+
return
|
|
71
|
+
self._store(body)
|
|
72
|
+
|
|
73
|
+
def _store(self, body: dict) -> None:
|
|
74
|
+
self._access = _token(body, "access_token")
|
|
75
|
+
expires_at = body.get("expires_at")
|
|
76
|
+
self._expires_at_ms = expires_at if isinstance(expires_at, int) else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _token(body: dict, name: str) -> str:
|
|
80
|
+
value = body.get(name)
|
|
81
|
+
if not isinstance(value, str) or not value:
|
|
82
|
+
raise ServerError("invalid_response", f"the token response has no {name}")
|
|
83
|
+
return value
|
blobhub/_blob.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""`Blob`: one blob's record, its revisions, and revision create."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from blobhub._revision import Revision, settled
|
|
9
|
+
from blobhub._transport import required, segment
|
|
10
|
+
from blobhub.errors import ServerError
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from blobhub._client import Client
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Blob:
|
|
17
|
+
"""A blob's record; after the first fetch it is addressed by its canonical org and blob ids."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, client: Client, record: dict) -> None:
|
|
20
|
+
self.client = client
|
|
21
|
+
self.record = record
|
|
22
|
+
|
|
23
|
+
def __repr__(self) -> str:
|
|
24
|
+
return f"Blob(id={self.id!r}, alias={self.alias!r}, type={self.type!r})"
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def id(self) -> str:
|
|
28
|
+
return self.record["id"]
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def alias(self) -> str | None:
|
|
32
|
+
return self.record.get("alias")
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def org_id(self) -> str:
|
|
36
|
+
return self.record["org_id"]
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def type(self) -> str:
|
|
40
|
+
return self.record["type"]
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def visibility(self) -> str | None:
|
|
44
|
+
return self.record.get("visibility")
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def status(self) -> str | None:
|
|
48
|
+
return self.record.get("status")
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def default_revision_id(self) -> str | None:
|
|
52
|
+
return self.record.get("default_revision_id")
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def _path(self) -> str:
|
|
56
|
+
return f"blobs/{segment(self.org_id)}/{segment(self.id)}"
|
|
57
|
+
|
|
58
|
+
def refresh(self) -> Blob:
|
|
59
|
+
"""Re-read the record (`GET /blobs/{org_id}/{id}`)."""
|
|
60
|
+
self.record = required(self.client._send("GET", self._path)[0], "blob")
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def revision(self, ref: str = "default") -> Revision:
|
|
64
|
+
"""The revision `ref`, a revision id or `default` (`GET …/revisions/{ref}`)."""
|
|
65
|
+
body = self.client._send("GET", f"{self._path}/revisions/{segment(ref)}")[0]
|
|
66
|
+
return Revision(self, required(body, "revision"))
|
|
67
|
+
|
|
68
|
+
def revisions(self, *, page_size: int = 50) -> Iterator[Revision]:
|
|
69
|
+
"""Every revision, newest first, following the cursor until the server returns none.
|
|
70
|
+
|
|
71
|
+
:param page_size: the `limit` sent with every page, 1 to 200.
|
|
72
|
+
"""
|
|
73
|
+
cursor = None
|
|
74
|
+
sent: set[str] = set()
|
|
75
|
+
while True:
|
|
76
|
+
params = {"limit": page_size, "cursor": cursor}
|
|
77
|
+
body = self.client._send("GET", f"{self._path}/revisions", params=params)[0]
|
|
78
|
+
for record in body.get("revisions", []):
|
|
79
|
+
yield Revision(self, record)
|
|
80
|
+
following = body.get("cursor")
|
|
81
|
+
if not following:
|
|
82
|
+
return
|
|
83
|
+
if following in sent:
|
|
84
|
+
raise ServerError("cursor_loop", "the revision listing returned a cursor it had already been sent")
|
|
85
|
+
sent.add(following)
|
|
86
|
+
cursor = following
|
|
87
|
+
|
|
88
|
+
def create_revision(self, comment: str, *, timeout: float = 600.0) -> Revision:
|
|
89
|
+
"""Create a revision from the committed default (`POST …/revisions`) and wait until it is `ready`.
|
|
90
|
+
|
|
91
|
+
:param comment: the new revision's comment, 1 to 200 characters.
|
|
92
|
+
:param timeout: seconds to wait before WaitTimeout.
|
|
93
|
+
"""
|
|
94
|
+
body = self.client._send("POST", f"{self._path}/revisions", json_body={"comment": comment})[0]
|
|
95
|
+
revision = Revision(self, {"id": required(body, "revision_id"), "blob_id": self.id, "status": "creating"})
|
|
96
|
+
return revision._wait_for("creating revision", timeout, settled)
|
blobhub/_cache.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""D8: where downloaded artifacts are cached, and when a cached copy may be reused.
|
|
2
|
+
|
|
3
|
+
Write protocol: call `invalidate` before (re)writing an artifact, write it to a temp file in the same
|
|
4
|
+
directory and `os.replace` it into place, then call `remember` last. A copy with no valid stamp is never reused.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
11
|
+
|
|
12
|
+
SIDECAR_SUFFIX = ".blobhub.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def cache_root() -> Path:
|
|
16
|
+
"""`$XDG_CACHE_HOME/blobhub` when that is an absolute path, else `~/.cache/blobhub`; read at call time."""
|
|
17
|
+
base = os.environ.get("XDG_CACHE_HOME", "")
|
|
18
|
+
root = Path(base) if base and Path(base).is_absolute() else Path.home() / ".cache"
|
|
19
|
+
return root / "blobhub"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def plain(name: str, what: str) -> str:
|
|
23
|
+
"""`name` itself when it is a plain file name; ValueError for empty, `.`, `..`, a separator, drive or NUL."""
|
|
24
|
+
if (
|
|
25
|
+
not name
|
|
26
|
+
or name in (".", "..")
|
|
27
|
+
or ":" in name
|
|
28
|
+
or "\x00" in name
|
|
29
|
+
or PurePosixPath(name).name != name
|
|
30
|
+
or PureWindowsPath(name).name != name
|
|
31
|
+
):
|
|
32
|
+
raise ValueError(f"{what} {name!r} is not a plain file name")
|
|
33
|
+
return name
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def artifact_path(revision_id: str, name: str) -> Path:
|
|
37
|
+
"""Where the cached copy of artifact `name` of revision `revision_id` lives."""
|
|
38
|
+
if name.endswith(SIDECAR_SUFFIX):
|
|
39
|
+
raise ValueError(f"artifact name {name!r} is not a plain file name")
|
|
40
|
+
return cache_root() / "revisions" / plain(revision_id, "revision id") / plain(name, "artifact name")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_reusable(path: Path, revision: dict) -> bool:
|
|
44
|
+
"""Whether `path` is a complete copy taken from this exact committed state of `revision`."""
|
|
45
|
+
if revision.get("phase") != "commit" or revision.get("status") != "ready" or not path.is_file():
|
|
46
|
+
return False
|
|
47
|
+
if revision.get("id") is None or revision.get("updated_at") is None:
|
|
48
|
+
return False
|
|
49
|
+
try:
|
|
50
|
+
stamp = json.loads(_sidecar(path).read_text())
|
|
51
|
+
except (OSError, ValueError):
|
|
52
|
+
return False
|
|
53
|
+
return (
|
|
54
|
+
isinstance(stamp, dict)
|
|
55
|
+
and stamp.get("revision_id") == revision.get("id")
|
|
56
|
+
and stamp.get("updated_at") == revision.get("updated_at")
|
|
57
|
+
and stamp.get("size") == path.stat().st_size
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def remember(path: Path, revision: dict) -> None:
|
|
62
|
+
"""Record, atomically, which revision state `path` was downloaded from; call it last, after `invalidate`."""
|
|
63
|
+
for key in ("id", "updated_at"):
|
|
64
|
+
if revision.get(key) is None:
|
|
65
|
+
raise ValueError(f"revision has no {key}")
|
|
66
|
+
stamp = {"revision_id": revision["id"], "updated_at": revision["updated_at"], "size": path.stat().st_size}
|
|
67
|
+
fd, temp = tempfile.mkstemp(dir=path.parent, prefix=".stamp-")
|
|
68
|
+
try:
|
|
69
|
+
with os.fdopen(fd, "w") as handle:
|
|
70
|
+
handle.write(json.dumps(stamp))
|
|
71
|
+
os.replace(temp, _sidecar(path))
|
|
72
|
+
except BaseException:
|
|
73
|
+
Path(temp).unlink(missing_ok=True)
|
|
74
|
+
raise
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def invalidate(path: Path) -> None:
|
|
78
|
+
"""Drop `path`'s stamp so it is not reused; call it before rewriting the artifact. Absent is fine."""
|
|
79
|
+
_sidecar(path).unlink(missing_ok=True)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _sidecar(path: Path) -> Path:
|
|
83
|
+
return path.with_name(path.name + SIDECAR_SUFFIX)
|
blobhub/_client.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""`connect()` and `Client`: resolved credentials, the transport, and the entry points to the platform."""
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import NamedTuple
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from blobhub import _settings
|
|
11
|
+
from blobhub._auth import AnonymousAuth, KeyAuth
|
|
12
|
+
from blobhub._blob import Blob
|
|
13
|
+
from blobhub._operation import Operation
|
|
14
|
+
from blobhub._transport import Transport, required, segment
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Identity(NamedTuple):
|
|
18
|
+
"""Where the client's key came from, and the API it talks to; never the key itself."""
|
|
19
|
+
|
|
20
|
+
source: str
|
|
21
|
+
api_url: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Client:
|
|
25
|
+
"""A connection to the BlobHub API. Build one with `blobhub.connect()`; use one per thread."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
settings: _settings.Settings,
|
|
30
|
+
*,
|
|
31
|
+
transport: httpx.BaseTransport | None = None,
|
|
32
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
33
|
+
clock: Callable[[], float] = time.monotonic,
|
|
34
|
+
wall_clock: Callable[[], float] = time.time,
|
|
35
|
+
rng: Callable[[], float] = random.random,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._identity = Identity(source=settings.source, api_url=settings.api_url)
|
|
38
|
+
self._sleep = sleep
|
|
39
|
+
self._clock = clock
|
|
40
|
+
self._transport = Transport(settings.api_url, transport=transport, sleep=sleep, rng=rng)
|
|
41
|
+
if settings.api_key is None:
|
|
42
|
+
self._transport.auth = AnonymousAuth(self._unauthenticated, wall_clock=wall_clock)
|
|
43
|
+
else:
|
|
44
|
+
self._transport.auth = KeyAuth(settings.api_key)
|
|
45
|
+
|
|
46
|
+
def __repr__(self) -> str:
|
|
47
|
+
return f"Client(source={self._identity.source!r}, api_url={self._identity.api_url!r})"
|
|
48
|
+
|
|
49
|
+
def __enter__(self) -> "Client":
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
53
|
+
self.close()
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def identity(self) -> Identity:
|
|
57
|
+
"""Where the key came from and which API this client talks to."""
|
|
58
|
+
return self._identity
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def anonymous(self) -> bool:
|
|
62
|
+
"""Whether this client reads with an anonymous token."""
|
|
63
|
+
return self._identity.source == "anonymous"
|
|
64
|
+
|
|
65
|
+
def close(self) -> None:
|
|
66
|
+
"""Close the underlying HTTP connections."""
|
|
67
|
+
self._transport.close()
|
|
68
|
+
|
|
69
|
+
def whoami(self) -> dict | None:
|
|
70
|
+
"""The calling user's record (`GET /users/me`), or None for an anonymous client."""
|
|
71
|
+
if self.anonymous:
|
|
72
|
+
return None
|
|
73
|
+
return required(self.request("GET", "users/me"), "user")
|
|
74
|
+
|
|
75
|
+
def blob(self, org: str, blob: str) -> Blob:
|
|
76
|
+
"""The blob `org/blob`, each part an id or an alias, fetched once (`GET /blobs/{org}/{blob}`)."""
|
|
77
|
+
return Blob(self, required(self._send("GET", f"blobs/{segment(org)}/{segment(blob)}")[0], "blob"))
|
|
78
|
+
|
|
79
|
+
def operation(self, operation_id: str) -> Operation:
|
|
80
|
+
"""The operation `operation_id`, fetched once (`GET /operations/{id}`)."""
|
|
81
|
+
body = self._send("GET", f"operations/{segment(operation_id)}")[0]
|
|
82
|
+
return Operation(self, required(body, "operation"))
|
|
83
|
+
|
|
84
|
+
def request(self, method: str, path: str, *, json: dict | None = None, params: dict | None = None) -> dict:
|
|
85
|
+
"""Send one request relative to the API URL and return its success envelope.
|
|
86
|
+
|
|
87
|
+
:param json: the JSON body, sent exactly as given.
|
|
88
|
+
:param params: query parameters; None values are left out.
|
|
89
|
+
"""
|
|
90
|
+
return self._send(method.upper(), path, json_body=json, params=params)[0]
|
|
91
|
+
|
|
92
|
+
def upload_part(self, part: dict, data: bytes) -> str:
|
|
93
|
+
"""PUT one part of a presigned upload plan and return its ETag; no credential is sent."""
|
|
94
|
+
upload = part["upload"]
|
|
95
|
+
return self._transport.put_part(upload["url"], dict(upload.get("headers") or {}), data)
|
|
96
|
+
|
|
97
|
+
def download_part(self, part: dict) -> bytes:
|
|
98
|
+
"""GET one part of a presigned download plan and check its length; no credential is sent."""
|
|
99
|
+
download = part["download"]
|
|
100
|
+
return self._transport.get_part(download["url"], dict(download.get("headers") or {}), part["size"])
|
|
101
|
+
|
|
102
|
+
def _send(self, method: str, path: str, **options: object) -> tuple[dict, bytes | None]:
|
|
103
|
+
return self._transport.send(method, path, **options)
|
|
104
|
+
|
|
105
|
+
def _unauthenticated(self, method: str, path: str, body: dict | None) -> dict:
|
|
106
|
+
return self._transport.send(method, path, json_body=body, authenticated=False)[0]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def connect(
|
|
110
|
+
*,
|
|
111
|
+
profile: str | None = None,
|
|
112
|
+
api_key: str | None = None,
|
|
113
|
+
api_url: str | None = None,
|
|
114
|
+
anonymous: bool = False,
|
|
115
|
+
transport: httpx.BaseTransport | None = None,
|
|
116
|
+
) -> Client:
|
|
117
|
+
"""A client for the first credential the ladder finds: argument, environment, profile, then anonymous.
|
|
118
|
+
|
|
119
|
+
:param profile: a profile name from ~/.blobhub/credentials.yaml.
|
|
120
|
+
:param api_key: an API key; outranks BLOBHUB_API_KEY and the profile.
|
|
121
|
+
:param api_url: the API base URL; outranks BLOBHUB_API_URL and the profile.
|
|
122
|
+
:param anonymous: read public content with an anonymous token, ignoring the profile file.
|
|
123
|
+
:param transport: an httpx transport for API and part requests (tests, proxies).
|
|
124
|
+
"""
|
|
125
|
+
settings = _settings.resolve(profile=profile, api_key=api_key, api_url=api_url, anonymous=anonymous)
|
|
126
|
+
return Client(settings, transport=transport)
|
blobhub/_credentials.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Read-only access to the profile file blobhub-cli writes: `~/.blobhub/credentials.yaml`."""
|
|
2
|
+
|
|
3
|
+
import stat
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from blobhub.errors import ConfigError
|
|
10
|
+
|
|
11
|
+
BLOBHUB_HOME = Path.home() / ".blobhub"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Profile(NamedTuple):
|
|
15
|
+
"""One stored profile: its API key and API URL."""
|
|
16
|
+
|
|
17
|
+
key: str
|
|
18
|
+
url: str
|
|
19
|
+
|
|
20
|
+
def __repr__(self) -> str:
|
|
21
|
+
return f"Profile(key='***', url={self.url!r})"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def credentials_path() -> Path:
|
|
25
|
+
"""The profile file's path, read from `BLOBHUB_HOME` at call time."""
|
|
26
|
+
return BLOBHUB_HOME / "credentials.yaml"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load() -> tuple[dict[str, Profile], str | None]:
|
|
30
|
+
"""The file's profiles and its `default`; `({}, None)` when the file does not exist. Never writes it."""
|
|
31
|
+
path = credentials_path()
|
|
32
|
+
text = _read(path)
|
|
33
|
+
if text is None:
|
|
34
|
+
return {}, None
|
|
35
|
+
try:
|
|
36
|
+
data = yaml.safe_load(text)
|
|
37
|
+
except yaml.YAMLError:
|
|
38
|
+
# PyYAML's problem marks quote the offending line, which may hold a key.
|
|
39
|
+
raise ConfigError("invalid_credentials_file", f"{path} is not valid YAML; run `blobhub login`") from None
|
|
40
|
+
if not isinstance(data, dict) or not isinstance(data.get("profiles"), dict):
|
|
41
|
+
raise ConfigError("invalid_credentials_file", f"{path} is not a credentials file; run `blobhub login`")
|
|
42
|
+
profiles = {}
|
|
43
|
+
for name, entry in data["profiles"].items():
|
|
44
|
+
if not isinstance(entry, dict) or not _filled(entry.get("key")) or not _filled(entry.get("url")):
|
|
45
|
+
raise ConfigError(
|
|
46
|
+
"invalid_credentials_file", f"{path} has a malformed profile {name!r}; run `blobhub login`"
|
|
47
|
+
)
|
|
48
|
+
profiles[str(name)] = Profile(key=entry["key"], url=entry["url"])
|
|
49
|
+
default = data.get("default")
|
|
50
|
+
if default is not None and not isinstance(default, str):
|
|
51
|
+
raise ConfigError(
|
|
52
|
+
"invalid_credentials_file", f"{path} has a default that is not a profile name; run `blobhub login`"
|
|
53
|
+
)
|
|
54
|
+
return profiles, default
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _read(path: Path) -> str | None:
|
|
58
|
+
"""The file's text once its mode is owner-only, or None when it does not exist."""
|
|
59
|
+
try:
|
|
60
|
+
if not path.exists():
|
|
61
|
+
return None
|
|
62
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
63
|
+
if mode & 0o077:
|
|
64
|
+
raise ConfigError(
|
|
65
|
+
"insecure_credentials_file",
|
|
66
|
+
f"{path} is readable by group or other (mode {oct(mode)}); run `chmod 600 {path}` or `blobhub login`",
|
|
67
|
+
)
|
|
68
|
+
return path.read_text(encoding="utf-8")
|
|
69
|
+
except OSError as error:
|
|
70
|
+
reason = error.strerror or type(error).__name__
|
|
71
|
+
raise ConfigError("unreadable_credentials_file", f"{path} cannot be read ({reason})") from None
|
|
72
|
+
except UnicodeDecodeError:
|
|
73
|
+
# The decode error holds the file's bytes, keys included.
|
|
74
|
+
raise ConfigError("invalid_credentials_file", f"{path} is not UTF-8 text; run `blobhub login`") from None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _filled(value: object) -> bool:
|
|
78
|
+
return isinstance(value, str) and bool(value)
|
blobhub/_operation.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""`Operation`: a long-running server-side operation, such as the processing of an ONNX upload."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from blobhub._transport import required, segment
|
|
8
|
+
from blobhub._wait import INTERVAL, wait_until
|
|
9
|
+
from blobhub.errors import OperationFailed
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from blobhub._client import Client
|
|
13
|
+
|
|
14
|
+
TERMINAL = ("completed", "failed")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Operation:
|
|
18
|
+
"""One operation's record (`GET /operations/{id}`) and a way to wait for its outcome."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, client: Client, record: dict) -> None:
|
|
21
|
+
self.client = client
|
|
22
|
+
self.record = record
|
|
23
|
+
|
|
24
|
+
def __repr__(self) -> str:
|
|
25
|
+
return f"Operation(id={self.id!r}, status={self.status!r})"
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def id(self) -> str:
|
|
29
|
+
return self.record["id"]
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def status(self) -> str | None:
|
|
33
|
+
return self.record.get("status")
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def type(self) -> str | None:
|
|
37
|
+
return self.record.get("type")
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def revision_id(self) -> str | None:
|
|
41
|
+
return self.record.get("revision_id")
|
|
42
|
+
|
|
43
|
+
def refresh(self) -> Operation:
|
|
44
|
+
"""Re-read the record."""
|
|
45
|
+
self.record = required(self.client._send("GET", f"operations/{segment(self.id)}")[0], "operation")
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def wait(self, *, timeout: float = 600.0, interval: float = INTERVAL) -> Operation:
|
|
49
|
+
"""Poll until the operation completes; OperationFailed if it fails, WaitTimeout past `timeout` seconds."""
|
|
50
|
+
wait_until(
|
|
51
|
+
self.refresh,
|
|
52
|
+
lambda operation: operation.status in TERMINAL,
|
|
53
|
+
what=f"operation {self.id}",
|
|
54
|
+
timeout=timeout,
|
|
55
|
+
interval=interval,
|
|
56
|
+
sleep=self.client._sleep,
|
|
57
|
+
clock=self.client._clock,
|
|
58
|
+
)
|
|
59
|
+
if self.status == "failed":
|
|
60
|
+
raise OperationFailed("operation_failed", f"operation {self.id} failed")
|
|
61
|
+
return self
|
blobhub/_retry.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""D5: which requests may be retried after which failures, and how long to wait between attempts."""
|
|
2
|
+
|
|
3
|
+
import email.utils
|
|
4
|
+
import math
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
from blobhub.errors import BlobHubError, NetworkError, RateLimited, RequestNotSent, ServerError
|
|
9
|
+
|
|
10
|
+
ATTEMPTS = 3
|
|
11
|
+
BASE_DELAY = 0.5
|
|
12
|
+
MAX_DELAY = 8.0
|
|
13
|
+
MAX_RETRY_AFTER = 60.0
|
|
14
|
+
|
|
15
|
+
SAFE = "safe"
|
|
16
|
+
UNSAFE = "unsafe"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def request_class(method: str, path: str) -> str:
|
|
20
|
+
"""SAFE for a GET or a `/data/query`, which can run twice harmlessly; UNSAFE for every other request."""
|
|
21
|
+
route = path.split("?", 1)[0].rstrip("/")
|
|
22
|
+
if method.upper() == "GET" or route.endswith("/data/query"):
|
|
23
|
+
return SAFE
|
|
24
|
+
return UNSAFE
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def should_retry(error: BlobHubError, kind: str) -> bool:
|
|
28
|
+
"""Whether a failed attempt of a request of class `kind` may be repeated."""
|
|
29
|
+
if isinstance(error, (RateLimited, RequestNotSent)):
|
|
30
|
+
return True
|
|
31
|
+
return kind == SAFE and isinstance(error, (ServerError, NetworkError))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def backoff(attempt: int, rng: Callable[[], float]) -> float:
|
|
35
|
+
"""The full-jitter delay after failed attempt number `attempt`, counting from 0."""
|
|
36
|
+
return rng() * min(MAX_DELAY, BASE_DELAY * 2**attempt)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_retry_after(value: str | None, *, now: Callable[[], float] = time.time) -> float | None:
|
|
40
|
+
"""Seconds named by a Retry-After value (delta-seconds or HTTP-date), clamped to [0, 60]; None if unusable."""
|
|
41
|
+
if not value:
|
|
42
|
+
return None
|
|
43
|
+
try:
|
|
44
|
+
seconds = float(value)
|
|
45
|
+
except ValueError:
|
|
46
|
+
try:
|
|
47
|
+
seconds = email.utils.parsedate_to_datetime(value).timestamp() - now()
|
|
48
|
+
except (TypeError, ValueError):
|
|
49
|
+
return None
|
|
50
|
+
if not math.isfinite(seconds):
|
|
51
|
+
return None
|
|
52
|
+
return min(max(seconds, 0.0), MAX_RETRY_AFTER)
|