dbbyte 0.1.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.
- dbbyte/__init__.py +49 -0
- dbbyte/client.py +165 -0
- dbbyte/errors.py +37 -0
- dbbyte/objects.py +128 -0
- dbbyte/py.typed +0 -0
- dbbyte/types.py +94 -0
- dbbyte/utils.py +11 -0
- dbbyte-0.1.0.dist-info/METADATA +163 -0
- dbbyte-0.1.0.dist-info/RECORD +12 -0
- dbbyte-0.1.0.dist-info/WHEEL +5 -0
- dbbyte-0.1.0.dist-info/licenses/LICENSE +21 -0
- dbbyte-0.1.0.dist-info/top_level.txt +1 -0
dbbyte/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from .client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, DBByteClient
|
|
6
|
+
from .errors import DBByteError
|
|
7
|
+
from .objects import ObjectsResource
|
|
8
|
+
from .types import (
|
|
9
|
+
DeleteResponse,
|
|
10
|
+
DownloadResult,
|
|
11
|
+
FileListItem,
|
|
12
|
+
FileVersionPreview,
|
|
13
|
+
HealthResponse,
|
|
14
|
+
ListObjectsResponse,
|
|
15
|
+
ListVersionsResponse,
|
|
16
|
+
SignedUrlResponse,
|
|
17
|
+
UploadedObject,
|
|
18
|
+
VersionEntry,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"DBByte",
|
|
23
|
+
"DBByteError",
|
|
24
|
+
"HealthResponse",
|
|
25
|
+
"UploadedObject",
|
|
26
|
+
"ListObjectsResponse",
|
|
27
|
+
"FileListItem",
|
|
28
|
+
"FileVersionPreview",
|
|
29
|
+
"SignedUrlResponse",
|
|
30
|
+
"ListVersionsResponse",
|
|
31
|
+
"VersionEntry",
|
|
32
|
+
"DeleteResponse",
|
|
33
|
+
"DownloadResult",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DBByte:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
api_key: str,
|
|
41
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
42
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._client = DBByteClient(api_key, base_url, timeout)
|
|
45
|
+
self.objects = ObjectsResource(self._client)
|
|
46
|
+
|
|
47
|
+
def health(self) -> HealthResponse:
|
|
48
|
+
result = self._client.request_json("GET", "/health")
|
|
49
|
+
return result # type: ignore[no-any-return]
|
dbbyte/client.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.parse
|
|
6
|
+
import urllib.request
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Any, Dict, NoReturn, Optional
|
|
9
|
+
|
|
10
|
+
from .errors import DBByteError
|
|
11
|
+
|
|
12
|
+
DEFAULT_BASE_URL = "https://api.dbbyte.com"
|
|
13
|
+
DEFAULT_TIMEOUT = 30.0
|
|
14
|
+
USER_AGENT = "dbbyte-python/0.1.0"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DBByteClient:
|
|
18
|
+
"""Thin HTTP wrapper. Paths passed to request_* do NOT include /v1 -
|
|
19
|
+
in production the Cloudflare Worker gateway in front of
|
|
20
|
+
api.dbbyte.com inserts /api/v1 itself. If you point base_url at a
|
|
21
|
+
Next.js dev server directly (bypassing the Worker), include /api/v1
|
|
22
|
+
in base_url instead."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
api_key: str,
|
|
27
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
28
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
29
|
+
) -> None:
|
|
30
|
+
if not api_key:
|
|
31
|
+
raise ValueError("DBByte: api_key is required")
|
|
32
|
+
self.api_key = api_key
|
|
33
|
+
self.base_url = base_url.rstrip("/")
|
|
34
|
+
self.timeout = timeout
|
|
35
|
+
|
|
36
|
+
def _build_url(self, path: str, query: Optional[Dict[str, Any]] = None) -> str:
|
|
37
|
+
url = f"{self.base_url}{path}"
|
|
38
|
+
if query:
|
|
39
|
+
filtered = {k: v for k, v in query.items() if v is not None}
|
|
40
|
+
if filtered:
|
|
41
|
+
url += "?" + urllib.parse.urlencode(filtered)
|
|
42
|
+
return url
|
|
43
|
+
|
|
44
|
+
def _headers(self, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
|
45
|
+
headers = {
|
|
46
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
47
|
+
"User-Agent": USER_AGENT,
|
|
48
|
+
}
|
|
49
|
+
if extra:
|
|
50
|
+
headers.update(extra)
|
|
51
|
+
return headers
|
|
52
|
+
|
|
53
|
+
def _raise_for_error(self, e: urllib.error.HTTPError) -> NoReturn:
|
|
54
|
+
raw = e.read()
|
|
55
|
+
retry_after = None
|
|
56
|
+
ra_header = e.headers.get("Retry-After") if e.headers else None
|
|
57
|
+
if ra_header is not None:
|
|
58
|
+
try:
|
|
59
|
+
retry_after = int(ra_header)
|
|
60
|
+
except ValueError:
|
|
61
|
+
retry_after = None
|
|
62
|
+
message = e.reason
|
|
63
|
+
hint = None
|
|
64
|
+
try:
|
|
65
|
+
body = json.loads(raw.decode("utf-8"))
|
|
66
|
+
message = body.get("error", message)
|
|
67
|
+
hint = body.get("hint")
|
|
68
|
+
except (ValueError, UnicodeDecodeError):
|
|
69
|
+
# Non-JSON body: this didn't come from the DBByte API itself
|
|
70
|
+
# (which always returns {"error": ...}) - likely blocked
|
|
71
|
+
# upstream, e.g. by Cloudflare's edge/WAF before reaching
|
|
72
|
+
# the app.
|
|
73
|
+
hint = "Response was not JSON - request may have been blocked before reaching the API"
|
|
74
|
+
raise DBByteError(message, e.code, hint, retry_after) from None
|
|
75
|
+
|
|
76
|
+
def request_json(
|
|
77
|
+
self,
|
|
78
|
+
method: str,
|
|
79
|
+
path: str,
|
|
80
|
+
query: Optional[Dict[str, Any]] = None,
|
|
81
|
+
body: Optional[Dict[str, Any]] = None,
|
|
82
|
+
) -> Any:
|
|
83
|
+
url = self._build_url(path, query)
|
|
84
|
+
data = None
|
|
85
|
+
headers = self._headers()
|
|
86
|
+
if body is not None:
|
|
87
|
+
data = json.dumps(body).encode("utf-8")
|
|
88
|
+
headers["Content-Type"] = "application/json"
|
|
89
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
90
|
+
try:
|
|
91
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as res:
|
|
92
|
+
raw = res.read()
|
|
93
|
+
return json.loads(raw.decode("utf-8")) if raw else None
|
|
94
|
+
except urllib.error.HTTPError as e:
|
|
95
|
+
self._raise_for_error(e)
|
|
96
|
+
except urllib.error.URLError as e:
|
|
97
|
+
raise DBByteError(f"Request failed: {e.reason}", 0) from None
|
|
98
|
+
|
|
99
|
+
def request_form(
|
|
100
|
+
self,
|
|
101
|
+
method: str,
|
|
102
|
+
path: str,
|
|
103
|
+
filename: str,
|
|
104
|
+
file_bytes: bytes,
|
|
105
|
+
content_type: Optional[str],
|
|
106
|
+
query: Optional[Dict[str, Any]] = None,
|
|
107
|
+
) -> Any:
|
|
108
|
+
url = self._build_url(path, query)
|
|
109
|
+
boundary = uuid.uuid4().hex
|
|
110
|
+
ct = content_type or "application/octet-stream"
|
|
111
|
+
# Filename is only escaped for embedded quotes, matching the
|
|
112
|
+
# Node SDK's simplicity - the server is the source of truth for
|
|
113
|
+
# what filenames it accepts.
|
|
114
|
+
safe_filename = filename.replace('"', '\\"')
|
|
115
|
+
parts = [
|
|
116
|
+
f"--{boundary}\r\n".encode(),
|
|
117
|
+
(
|
|
118
|
+
f'Content-Disposition: form-data; name="file"; '
|
|
119
|
+
f'filename="{safe_filename}"\r\n'
|
|
120
|
+
f"Content-Type: {ct}\r\n\r\n"
|
|
121
|
+
).encode(),
|
|
122
|
+
file_bytes,
|
|
123
|
+
f"\r\n--{boundary}--\r\n".encode(),
|
|
124
|
+
]
|
|
125
|
+
data = b"".join(parts)
|
|
126
|
+
|
|
127
|
+
headers = self._headers(
|
|
128
|
+
{"Content-Type": f"multipart/form-data; boundary={boundary}"}
|
|
129
|
+
)
|
|
130
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
131
|
+
try:
|
|
132
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as res:
|
|
133
|
+
raw = res.read()
|
|
134
|
+
return json.loads(raw.decode("utf-8")) if raw else None
|
|
135
|
+
except urllib.error.HTTPError as e:
|
|
136
|
+
self._raise_for_error(e)
|
|
137
|
+
except urllib.error.URLError as e:
|
|
138
|
+
raise DBByteError(f"Request failed: {e.reason}", 0) from None
|
|
139
|
+
|
|
140
|
+
def request_binary(
|
|
141
|
+
self,
|
|
142
|
+
method: str,
|
|
143
|
+
path: str,
|
|
144
|
+
query: Optional[Dict[str, Any]] = None,
|
|
145
|
+
) -> Dict[str, Any]:
|
|
146
|
+
url = self._build_url(path, query)
|
|
147
|
+
req = urllib.request.Request(url, headers=self._headers(), method=method)
|
|
148
|
+
try:
|
|
149
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as res:
|
|
150
|
+
data = res.read()
|
|
151
|
+
content_type = res.headers.get(
|
|
152
|
+
"Content-Type", "application/octet-stream"
|
|
153
|
+
)
|
|
154
|
+
disposition = res.headers.get("Content-Disposition")
|
|
155
|
+
filename = None
|
|
156
|
+
if disposition and "filename=" in disposition:
|
|
157
|
+
filename = disposition.split("filename=", 1)[1].strip('"')
|
|
158
|
+
result: Dict[str, Any] = {"data": data, "content_type": content_type}
|
|
159
|
+
if filename:
|
|
160
|
+
result["filename"] = filename
|
|
161
|
+
return result
|
|
162
|
+
except urllib.error.HTTPError as e:
|
|
163
|
+
self._raise_for_error(e)
|
|
164
|
+
except urllib.error.URLError as e:
|
|
165
|
+
raise DBByteError(f"Request failed: {e.reason}", 0) from None
|
dbbyte/errors.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DBByteError(Exception):
|
|
7
|
+
"""Raised on any DBByte API error.
|
|
8
|
+
|
|
9
|
+
The API returns {"error": str, "hint"?: str} on failure. There is no
|
|
10
|
+
"code" field - do not rely on one.
|
|
11
|
+
|
|
12
|
+
A 429 can mean two different things and only one is retryable:
|
|
13
|
+
- Burst rate limit: retry_after is set (seconds until you can
|
|
14
|
+
retry). Transient, safe to retry after that delay.
|
|
15
|
+
- Plan API request quota exceeded: retry_after is None. Retrying
|
|
16
|
+
immediately won't help until the billing period resets or the
|
|
17
|
+
plan is upgraded.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
message: str,
|
|
23
|
+
status: int,
|
|
24
|
+
hint: Optional[str] = None,
|
|
25
|
+
retry_after: Optional[int] = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.status = status
|
|
29
|
+
self.message = message
|
|
30
|
+
self.hint = hint
|
|
31
|
+
self.retry_after = retry_after
|
|
32
|
+
|
|
33
|
+
def __repr__(self) -> str:
|
|
34
|
+
return (
|
|
35
|
+
f"DBByteError(status={self.status!r}, message={self.message!r}, "
|
|
36
|
+
f"hint={self.hint!r}, retry_after={self.retry_after!r})"
|
|
37
|
+
)
|
dbbyte/objects.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from .client import DBByteClient
|
|
6
|
+
from .types import (
|
|
7
|
+
DeleteResponse,
|
|
8
|
+
DownloadResult,
|
|
9
|
+
ListObjectsResponse,
|
|
10
|
+
ListVersionsResponse,
|
|
11
|
+
SignedUrlResponse,
|
|
12
|
+
UploadedObject,
|
|
13
|
+
)
|
|
14
|
+
from .utils import encode_key
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ObjectsResource:
|
|
18
|
+
def __init__(self, client: DBByteClient) -> None:
|
|
19
|
+
self._client = client
|
|
20
|
+
|
|
21
|
+
def upload(
|
|
22
|
+
self,
|
|
23
|
+
bucket: str,
|
|
24
|
+
key: str,
|
|
25
|
+
body: bytes,
|
|
26
|
+
content_type: Optional[str] = None,
|
|
27
|
+
overwrite: bool = False,
|
|
28
|
+
) -> UploadedObject:
|
|
29
|
+
result = self._client.request_form(
|
|
30
|
+
"POST",
|
|
31
|
+
f"/storage/{bucket}/upload",
|
|
32
|
+
filename=key,
|
|
33
|
+
file_bytes=body,
|
|
34
|
+
content_type=content_type,
|
|
35
|
+
query={"overwrite": "true" if overwrite else None},
|
|
36
|
+
)
|
|
37
|
+
return result # type: ignore[no-any-return]
|
|
38
|
+
|
|
39
|
+
def list(
|
|
40
|
+
self,
|
|
41
|
+
bucket: str,
|
|
42
|
+
prefix: Optional[str] = None,
|
|
43
|
+
cursor: Optional[str] = None,
|
|
44
|
+
limit: Optional[int] = None,
|
|
45
|
+
) -> ListObjectsResponse:
|
|
46
|
+
result = self._client.request_json(
|
|
47
|
+
"GET",
|
|
48
|
+
f"/storage/{bucket}/list",
|
|
49
|
+
query={"prefix": prefix, "cursor": cursor, "limit": limit},
|
|
50
|
+
)
|
|
51
|
+
return result # type: ignore[no-any-return]
|
|
52
|
+
|
|
53
|
+
def download(
|
|
54
|
+
self,
|
|
55
|
+
bucket: str,
|
|
56
|
+
key: str,
|
|
57
|
+
version: Optional[str] = None,
|
|
58
|
+
) -> DownloadResult:
|
|
59
|
+
result = self._client.request_binary(
|
|
60
|
+
"GET",
|
|
61
|
+
f"/storage/{bucket}/download/{encode_key(key)}",
|
|
62
|
+
query={"version": version},
|
|
63
|
+
)
|
|
64
|
+
return result # type: ignore[return-value]
|
|
65
|
+
|
|
66
|
+
def download_all_versions(self, bucket: str, key: str) -> DownloadResult:
|
|
67
|
+
"""Returns a zip of every version of this key."""
|
|
68
|
+
result = self._client.request_binary(
|
|
69
|
+
"GET",
|
|
70
|
+
f"/storage/{bucket}/download/{encode_key(key)}",
|
|
71
|
+
query={"version": "all"},
|
|
72
|
+
)
|
|
73
|
+
return result # type: ignore[return-value]
|
|
74
|
+
|
|
75
|
+
def delete(
|
|
76
|
+
self,
|
|
77
|
+
bucket: str,
|
|
78
|
+
key: str,
|
|
79
|
+
version: Optional[str] = None,
|
|
80
|
+
) -> DeleteResponse:
|
|
81
|
+
result = self._client.request_json(
|
|
82
|
+
"DELETE",
|
|
83
|
+
f"/storage/{bucket}/delete/{encode_key(key)}",
|
|
84
|
+
query={"version": version},
|
|
85
|
+
)
|
|
86
|
+
return result # type: ignore[no-any-return]
|
|
87
|
+
|
|
88
|
+
def delete_all_versions(self, bucket: str, key: str) -> DeleteResponse:
|
|
89
|
+
"""Permanently deletes every version of this key."""
|
|
90
|
+
result = self._client.request_json(
|
|
91
|
+
"DELETE",
|
|
92
|
+
f"/storage/{bucket}/delete/{encode_key(key)}",
|
|
93
|
+
query={"version": "all"},
|
|
94
|
+
)
|
|
95
|
+
return result # type: ignore[no-any-return]
|
|
96
|
+
|
|
97
|
+
def sign(
|
|
98
|
+
self,
|
|
99
|
+
bucket: str,
|
|
100
|
+
key: str,
|
|
101
|
+
version: Optional[str] = None,
|
|
102
|
+
expires_in: Optional[int] = None,
|
|
103
|
+
) -> SignedUrlResponse:
|
|
104
|
+
body: Dict[str, Any] = {}
|
|
105
|
+
if version is not None:
|
|
106
|
+
body["version"] = version
|
|
107
|
+
if expires_in is not None:
|
|
108
|
+
body["expiresIn"] = expires_in
|
|
109
|
+
result = self._client.request_json(
|
|
110
|
+
"POST",
|
|
111
|
+
f"/storage/{bucket}/sign/{encode_key(key)}",
|
|
112
|
+
body=body,
|
|
113
|
+
)
|
|
114
|
+
return result # type: ignore[no-any-return]
|
|
115
|
+
|
|
116
|
+
def versions(
|
|
117
|
+
self,
|
|
118
|
+
bucket: str,
|
|
119
|
+
key: str,
|
|
120
|
+
limit: Optional[int] = None,
|
|
121
|
+
cursor: Optional[str] = None,
|
|
122
|
+
) -> ListVersionsResponse:
|
|
123
|
+
result = self._client.request_json(
|
|
124
|
+
"GET",
|
|
125
|
+
f"/storage/{bucket}/versions/{encode_key(key)}",
|
|
126
|
+
query={"limit": limit, "cursor": cursor},
|
|
127
|
+
)
|
|
128
|
+
return result # type: ignore[no-any-return]
|
dbbyte/py.typed
ADDED
|
File without changes
|
dbbyte/types.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Optional, TypedDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HealthChecks(TypedDict):
|
|
7
|
+
api: Dict[str, Any]
|
|
8
|
+
database: Dict[str, Any]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HealthResponse(TypedDict):
|
|
12
|
+
status: str # "ok" | "degraded"
|
|
13
|
+
timestamp: str
|
|
14
|
+
uptimeSeconds: int
|
|
15
|
+
checks: HealthChecks
|
|
16
|
+
responseTimeMs: int
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class UploadedObject(TypedDict):
|
|
20
|
+
"""Returned on a successful upload. Matches the storage server's
|
|
21
|
+
real response shape exactly - not inferred."""
|
|
22
|
+
|
|
23
|
+
success: bool
|
|
24
|
+
key: str # server-generated storage key, not the name you passed
|
|
25
|
+
name: str # the name you uploaded it as
|
|
26
|
+
size: int
|
|
27
|
+
mimeType: str
|
|
28
|
+
fileId: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FileVersionPreview(TypedDict):
|
|
32
|
+
key: str
|
|
33
|
+
size: int
|
|
34
|
+
mimeType: str
|
|
35
|
+
isLatest: bool
|
|
36
|
+
createdAt: str
|
|
37
|
+
updatedAt: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FileListItem(TypedDict):
|
|
41
|
+
key: str
|
|
42
|
+
name: str
|
|
43
|
+
size: int
|
|
44
|
+
isPublic: bool
|
|
45
|
+
createdAt: str
|
|
46
|
+
updatedAt: str
|
|
47
|
+
versionCount: int
|
|
48
|
+
versions: List[FileVersionPreview]
|
|
49
|
+
hasMoreVersions: bool
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ListObjectsResponse(TypedDict):
|
|
53
|
+
files: List[FileListItem]
|
|
54
|
+
nextCursor: Optional[str]
|
|
55
|
+
hasMore: bool
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SignedUrlResponse(TypedDict):
|
|
59
|
+
url: str
|
|
60
|
+
expiresIn: int
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class VersionEntry(TypedDict):
|
|
64
|
+
versionKey: str
|
|
65
|
+
size: int
|
|
66
|
+
mimeType: str
|
|
67
|
+
isLatest: bool
|
|
68
|
+
isDeleted: bool
|
|
69
|
+
createdAt: str
|
|
70
|
+
updatedAt: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ListVersionsResponse(TypedDict):
|
|
74
|
+
versions: List[VersionEntry]
|
|
75
|
+
nextCursor: Optional[str]
|
|
76
|
+
hasMore: bool
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _DeleteResponseOptional(TypedDict, total=False):
|
|
80
|
+
# Only present when deleting every version at once.
|
|
81
|
+
deletedVersions: int
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class DeleteResponse(_DeleteResponseOptional):
|
|
85
|
+
success: bool
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _DownloadResultOptional(TypedDict, total=False):
|
|
89
|
+
filename: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class DownloadResult(_DownloadResultOptional):
|
|
93
|
+
data: bytes
|
|
94
|
+
content_type: str
|
dbbyte/utils.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def encode_key(key: str) -> str:
|
|
7
|
+
"""Object keys can contain slashes (e.g. "images/logo.png"). The
|
|
8
|
+
API's catch-all routes expect each path segment encoded
|
|
9
|
+
individually, then rejoined with "/" - encoding the whole key would
|
|
10
|
+
also encode the slashes themselves."""
|
|
11
|
+
return "/".join(quote(segment, safe="") for segment in key.split("/"))
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dbbyte
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official DBByte SDK for Python
|
|
5
|
+
Author: DBByte, LLC
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://dbbyte.com
|
|
8
|
+
Project-URL: Repository, https://github.com/dbbyte/python-sdk
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# dbbyte
|
|
19
|
+
|
|
20
|
+
Official DBByte SDK for Python. A thin, typed wrapper over the DBByte v1 REST API. Standard library only - no dependencies.
|
|
21
|
+
|
|
22
|
+
## Status
|
|
23
|
+
|
|
24
|
+
Covers object storage: upload, list, download, delete, versioning, and signed URLs. There is no bucket management, CDN cache control, or analytics API yet - those aren't part of the v1 REST API, so they aren't in the SDK.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install dbbyte
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Requires Python 3.9+.
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import os
|
|
38
|
+
from dbbyte import DBByte
|
|
39
|
+
|
|
40
|
+
db = DBByte(api_key=os.environ["DBBYTE_API_KEY"])
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
| Argument | Type | Description |
|
|
44
|
+
| --- | --- | --- |
|
|
45
|
+
| `api_key` | `str` | Your DBByte API key. Required. |
|
|
46
|
+
| `base_url` | `str` | Override the API base URL. Defaults to `https://api.dbbyte.com`. |
|
|
47
|
+
| `timeout` | `float` | Request timeout in seconds. Defaults to `30.0`. |
|
|
48
|
+
|
|
49
|
+
### Health check
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
status = db.health()
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Upload
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
with open("logo.png", "rb") as f:
|
|
59
|
+
body = f.read()
|
|
60
|
+
|
|
61
|
+
obj = db.objects.upload(
|
|
62
|
+
"my-bucket",
|
|
63
|
+
key="images/logo.png",
|
|
64
|
+
body=body,
|
|
65
|
+
content_type="image/png",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Re-uploading the same name fails with 409 unless you opt in:
|
|
69
|
+
db.objects.upload("my-bucket", key="images/logo.png", body=body, overwrite=True)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### List
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
result = db.objects.list("my-bucket")
|
|
76
|
+
files = result["files"]
|
|
77
|
+
|
|
78
|
+
# Paginate:
|
|
79
|
+
next_page = db.objects.list("my-bucket", cursor=result["nextCursor"])
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Download
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
result = db.objects.download("my-bucket", "images/logo.png")
|
|
86
|
+
data = result["data"] # bytes
|
|
87
|
+
|
|
88
|
+
# A specific historical version:
|
|
89
|
+
db.objects.download("my-bucket", "images/logo.png", version="some-version-key")
|
|
90
|
+
|
|
91
|
+
# Every version, zipped:
|
|
92
|
+
zip_result = db.objects.download_all_versions("my-bucket", "images/logo.png")
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Versions
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
result = db.objects.versions("my-bucket", "images/logo.png")
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Signed URLs
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
result = db.objects.sign("my-bucket", "images/logo.png", expires_in=3600) # max 604800 (7 days)
|
|
105
|
+
url = result["url"]
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Delete
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
db.objects.delete("my-bucket", "images/logo.png")
|
|
112
|
+
|
|
113
|
+
# A specific version only:
|
|
114
|
+
db.objects.delete("my-bucket", "images/logo.png", version="some-version-key")
|
|
115
|
+
|
|
116
|
+
# Every version, permanently:
|
|
117
|
+
db.objects.delete_all_versions("my-bucket", "images/logo.png")
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Error handling
|
|
121
|
+
|
|
122
|
+
All SDK methods raise a `DBByteError` on API errors.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from dbbyte import DBByte, DBByteError
|
|
126
|
+
|
|
127
|
+
db = DBByte(api_key=os.environ["DBBYTE_API_KEY"])
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
db.objects.download("my-bucket", "does-not-exist.png")
|
|
131
|
+
except DBByteError as e:
|
|
132
|
+
print(e.status) # e.g. 404
|
|
133
|
+
print(e.message) # e.g. "File not found"
|
|
134
|
+
print(e.hint) # present on some errors, e.g. upload conflicts
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Rate limits vs quota limits
|
|
138
|
+
|
|
139
|
+
A `429` can mean two different things, and only one is worth retrying:
|
|
140
|
+
|
|
141
|
+
- **Burst rate limit** - `e.retry_after` is set (seconds until you can retry). Transient, safe to retry after that delay.
|
|
142
|
+
- **Plan API request quota exceeded** - `e.retry_after` is `None`. Retrying immediately won't help; you're out of quota until your billing period resets or you upgrade.
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
except DBByteError as e:
|
|
146
|
+
if e.status == 429:
|
|
147
|
+
if e.retry_after is not None:
|
|
148
|
+
# wait e.retry_after seconds, then retry
|
|
149
|
+
pass
|
|
150
|
+
else:
|
|
151
|
+
# plan quota exceeded, don't retry blindly
|
|
152
|
+
pass
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Known limitations
|
|
156
|
+
|
|
157
|
+
- **Upload allowed types**: the server enforces a fixed MIME type whitelist (common image, video, audio, PDF, Word, and plain-text formats). Uploading anything else returns a `400` from the server - the SDK doesn't pre-validate this client-side.
|
|
158
|
+
- **50MB max file size**, enforced server-side.
|
|
159
|
+
- **Synchronous only.** No async client yet.
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
dbbyte/__init__.py,sha256=oPgWsCtp3Yz9uwIloSRgIUWhVYk0nOMezwZK4H9oWcA,1162
|
|
2
|
+
dbbyte/client.py,sha256=oG38mUu1445xG0dFcSjtX4TGxFExEYuImbkyNezRYrk,6205
|
|
3
|
+
dbbyte/errors.py,sha256=ji_WLZ_WD9LxMrFtVU29b7_vol8B5OVbKlC7Cbl6mzU,1152
|
|
4
|
+
dbbyte/objects.py,sha256=atxpsMPU8BR3s7MeNDAacx54IuNUWsW9Fe2Upq8LEx0,3792
|
|
5
|
+
dbbyte/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
dbbyte/types.py,sha256=ZaN1Gjk9OqmrXDIIv3qekP7Xs4HYQNKYo-Yviy4HUe8,1876
|
|
7
|
+
dbbyte/utils.py,sha256=gfsDOIhMlT1iG16lldRAEDB8x18-S-APMBxZxhBhp8k,421
|
|
8
|
+
dbbyte-0.1.0.dist-info/licenses/LICENSE,sha256=gLycZuuP67_sfgACPG4TWFugJi7cpQMFHEcd1KMZ5jA,1068
|
|
9
|
+
dbbyte-0.1.0.dist-info/METADATA,sha256=95VJ0B6jaY6tgYUCWPCKsR_HZrmzTZsMSXdTcflIp0g,4371
|
|
10
|
+
dbbyte-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
dbbyte-0.1.0.dist-info/top_level.txt,sha256=aaFgCSCELmGozk0K4pT0Aj97PzKkMMSrU41EZ8xRnH8,7
|
|
12
|
+
dbbyte-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DBByte, LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dbbyte
|