teepin 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.
- teepin/__init__.py +187 -0
- teepin/_core.py +344 -0
- teepin/_inference.py +284 -0
- teepin/_version.py +4 -0
- teepin/async_client.py +261 -0
- teepin/async_inference.py +275 -0
- teepin/async_storage.py +1102 -0
- teepin/client.py +265 -0
- teepin/exceptions.py +215 -0
- teepin/inference.py +289 -0
- teepin/models.py +427 -0
- teepin/py.typed +0 -0
- teepin/storage.py +1242 -0
- teepin-0.2.0.dist-info/METADATA +478 -0
- teepin-0.2.0.dist-info/RECORD +18 -0
- teepin-0.2.0.dist-info/WHEEL +5 -0
- teepin-0.2.0.dist-info/licenses/LICENSE +69 -0
- teepin-0.2.0.dist-info/top_level.txt +1 -0
teepin/__init__.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Copyright 2026 TEEPIN Project
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""The Python SDK of Teepin Web Services (TWS): Teepin Storage, Teepin
|
|
5
|
+
Inference and compute.
|
|
6
|
+
|
|
7
|
+
>>> from teepin import Client
|
|
8
|
+
>>> client = Client(api_key="tpk_...")
|
|
9
|
+
>>> client.storage.upload_file("my-bucket", "videos/a.mp4", "a.mp4")
|
|
10
|
+
>>> client.chat.completions.create(model="teepin/llama-3.3-70b",
|
|
11
|
+
... messages=[{"role": "user", "content": "Hello"}])
|
|
12
|
+
|
|
13
|
+
:class:`AsyncClient` is the same API for asyncio.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from ._core import (
|
|
17
|
+
DEFAULT_CONCURRENCY,
|
|
18
|
+
DEFAULT_PART_SIZE,
|
|
19
|
+
MAX_OBJECT_SIZE,
|
|
20
|
+
MAX_PART_SIZE,
|
|
21
|
+
MAX_PARTS,
|
|
22
|
+
MAX_PUT_SIZE,
|
|
23
|
+
MIN_PART_SIZE,
|
|
24
|
+
MULTIPART_THRESHOLD,
|
|
25
|
+
ProgressFunc,
|
|
26
|
+
compute_etag,
|
|
27
|
+
part_size_for,
|
|
28
|
+
)
|
|
29
|
+
from ._inference import ChatCompletionParams, ChatMessageParam
|
|
30
|
+
from ._version import __version__
|
|
31
|
+
from .async_client import AsyncClient, AsyncInstancesService
|
|
32
|
+
from .async_inference import (
|
|
33
|
+
AsyncChatCompletionsService,
|
|
34
|
+
AsyncChatCompletionStream,
|
|
35
|
+
AsyncChatService,
|
|
36
|
+
AsyncModelsService,
|
|
37
|
+
)
|
|
38
|
+
from .async_storage import AsyncObjectReader, AsyncStorageService
|
|
39
|
+
from .client import Client, InstancesService
|
|
40
|
+
from .exceptions import (
|
|
41
|
+
APIConnectionError,
|
|
42
|
+
APIError,
|
|
43
|
+
BadRequestError,
|
|
44
|
+
ChecksumMismatchError,
|
|
45
|
+
ConflictError,
|
|
46
|
+
ForbiddenError,
|
|
47
|
+
InsufficientCreditError,
|
|
48
|
+
NoCreditError,
|
|
49
|
+
NotFoundError,
|
|
50
|
+
ObjectChangedError,
|
|
51
|
+
OnHoldError,
|
|
52
|
+
PaymentRequiredError,
|
|
53
|
+
PermissionDeniedError,
|
|
54
|
+
PreconditionFailedError,
|
|
55
|
+
RateLimitError,
|
|
56
|
+
StreamError,
|
|
57
|
+
TeepinError,
|
|
58
|
+
TooLargeError,
|
|
59
|
+
UnauthorizedError,
|
|
60
|
+
UploadError,
|
|
61
|
+
error_code,
|
|
62
|
+
is_retryable,
|
|
63
|
+
)
|
|
64
|
+
from .inference import ChatCompletionsService, ChatCompletionStream, ChatService, ModelsService
|
|
65
|
+
from .models import (
|
|
66
|
+
Bucket,
|
|
67
|
+
ChatCompletion,
|
|
68
|
+
ChatCompletionChunk,
|
|
69
|
+
ChatMessage,
|
|
70
|
+
Choice,
|
|
71
|
+
ChoiceDelta,
|
|
72
|
+
ChunkChoice,
|
|
73
|
+
CompletedPart,
|
|
74
|
+
CreateInstanceRequest,
|
|
75
|
+
DeleteError,
|
|
76
|
+
DeleteResult,
|
|
77
|
+
FunctionCall,
|
|
78
|
+
Hold,
|
|
79
|
+
InferenceModel,
|
|
80
|
+
Instance,
|
|
81
|
+
InstanceType,
|
|
82
|
+
ModelAttestation,
|
|
83
|
+
ModelPricing,
|
|
84
|
+
ObjectInfo,
|
|
85
|
+
ObjectPage,
|
|
86
|
+
Part,
|
|
87
|
+
PortMapping,
|
|
88
|
+
RateLimit,
|
|
89
|
+
ResponseMeta,
|
|
90
|
+
SignedUpload,
|
|
91
|
+
SignedURL,
|
|
92
|
+
StorageObject,
|
|
93
|
+
ToolCall,
|
|
94
|
+
Upload,
|
|
95
|
+
Usage,
|
|
96
|
+
)
|
|
97
|
+
from .storage import ObjectReader, StorageService
|
|
98
|
+
|
|
99
|
+
__all__ = [
|
|
100
|
+
"__version__",
|
|
101
|
+
# clients
|
|
102
|
+
"Client",
|
|
103
|
+
"AsyncClient",
|
|
104
|
+
"StorageService",
|
|
105
|
+
"AsyncStorageService",
|
|
106
|
+
"InstancesService",
|
|
107
|
+
"AsyncInstancesService",
|
|
108
|
+
"ObjectReader",
|
|
109
|
+
"AsyncObjectReader",
|
|
110
|
+
"ModelsService",
|
|
111
|
+
"AsyncModelsService",
|
|
112
|
+
"ChatService",
|
|
113
|
+
"AsyncChatService",
|
|
114
|
+
"ChatCompletionsService",
|
|
115
|
+
"AsyncChatCompletionsService",
|
|
116
|
+
"ChatCompletionStream",
|
|
117
|
+
"AsyncChatCompletionStream",
|
|
118
|
+
# storage
|
|
119
|
+
"Bucket",
|
|
120
|
+
"StorageObject",
|
|
121
|
+
"ObjectInfo",
|
|
122
|
+
"ObjectPage",
|
|
123
|
+
"Hold",
|
|
124
|
+
"DeleteError",
|
|
125
|
+
"DeleteResult",
|
|
126
|
+
"SignedURL",
|
|
127
|
+
"SignedUpload",
|
|
128
|
+
"Upload",
|
|
129
|
+
"Part",
|
|
130
|
+
"CompletedPart",
|
|
131
|
+
"compute_etag",
|
|
132
|
+
"part_size_for",
|
|
133
|
+
"ProgressFunc",
|
|
134
|
+
"MIN_PART_SIZE",
|
|
135
|
+
"MAX_PART_SIZE",
|
|
136
|
+
"MAX_PARTS",
|
|
137
|
+
"MAX_OBJECT_SIZE",
|
|
138
|
+
"MAX_PUT_SIZE",
|
|
139
|
+
"DEFAULT_PART_SIZE",
|
|
140
|
+
"MULTIPART_THRESHOLD",
|
|
141
|
+
"DEFAULT_CONCURRENCY",
|
|
142
|
+
# compute
|
|
143
|
+
"Instance",
|
|
144
|
+
"CreateInstanceRequest",
|
|
145
|
+
"InstanceType",
|
|
146
|
+
"PortMapping",
|
|
147
|
+
# inference
|
|
148
|
+
"InferenceModel",
|
|
149
|
+
"ModelPricing",
|
|
150
|
+
"ModelAttestation",
|
|
151
|
+
"ChatMessageParam",
|
|
152
|
+
"ChatCompletionParams",
|
|
153
|
+
"ChatCompletion",
|
|
154
|
+
"ChatMessage",
|
|
155
|
+
"Choice",
|
|
156
|
+
"ChatCompletionChunk",
|
|
157
|
+
"ChunkChoice",
|
|
158
|
+
"ChoiceDelta",
|
|
159
|
+
"ToolCall",
|
|
160
|
+
"FunctionCall",
|
|
161
|
+
"Usage",
|
|
162
|
+
"ResponseMeta",
|
|
163
|
+
"RateLimit",
|
|
164
|
+
# errors
|
|
165
|
+
"TeepinError",
|
|
166
|
+
"APIError",
|
|
167
|
+
"BadRequestError",
|
|
168
|
+
"UnauthorizedError",
|
|
169
|
+
"PaymentRequiredError",
|
|
170
|
+
"NoCreditError",
|
|
171
|
+
"InsufficientCreditError",
|
|
172
|
+
"OnHoldError",
|
|
173
|
+
"ForbiddenError",
|
|
174
|
+
"PermissionDeniedError",
|
|
175
|
+
"RateLimitError",
|
|
176
|
+
"StreamError",
|
|
177
|
+
"NotFoundError",
|
|
178
|
+
"ConflictError",
|
|
179
|
+
"PreconditionFailedError",
|
|
180
|
+
"TooLargeError",
|
|
181
|
+
"APIConnectionError",
|
|
182
|
+
"ObjectChangedError",
|
|
183
|
+
"ChecksumMismatchError",
|
|
184
|
+
"UploadError",
|
|
185
|
+
"error_code",
|
|
186
|
+
"is_retryable",
|
|
187
|
+
]
|
teepin/_core.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
# Copyright 2026 TEEPIN Project
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""What the synchronous and asynchronous clients share: retry rules, error
|
|
5
|
+
decoding, ETags and part sizes. Nothing here does I/O on the network, so the
|
|
6
|
+
two clients cannot drift apart on any of it.
|
|
7
|
+
|
|
8
|
+
The rules match the Go and Node SDKs (teepin-go/teepin, teepin-node/src).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import mimetypes
|
|
16
|
+
import os
|
|
17
|
+
import random
|
|
18
|
+
import threading
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from typing import IO, Callable, Dict, Iterable, List, Mapping, Optional, Tuple, Union
|
|
22
|
+
from urllib.parse import quote
|
|
23
|
+
|
|
24
|
+
from .exceptions import APIConnectionError, APIError, api_error
|
|
25
|
+
|
|
26
|
+
DEFAULT_BASE_URL = "https://api.teepin.com"
|
|
27
|
+
DEFAULT_TIMEOUT = 60.0
|
|
28
|
+
#: Told the bytes moved so far and the total (-1 when not known yet).
|
|
29
|
+
ProgressFunc = Callable[[int, int], None]
|
|
30
|
+
|
|
31
|
+
#: How long a transfer may go without receiving a byte before it is cut off.
|
|
32
|
+
DEFAULT_TRANSFER_READ_TIMEOUT = 300.0
|
|
33
|
+
DEFAULT_RETRIES = 4
|
|
34
|
+
#: Retries of a Teepin Inference request (INFERENCE-SDK-CONTRACT.md).
|
|
35
|
+
DEFAULT_INFERENCE_RETRIES = 2
|
|
36
|
+
#: How long a chat request waits for its answer (a stream: for its headers).
|
|
37
|
+
#: A long answer takes minutes, and the server sends nothing until it is done.
|
|
38
|
+
DEFAULT_CHAT_TIMEOUT = 600.0
|
|
39
|
+
|
|
40
|
+
#: The smallest part but the last.
|
|
41
|
+
MIN_PART_SIZE = 5 << 20
|
|
42
|
+
#: The largest part.
|
|
43
|
+
MAX_PART_SIZE = 5 << 30
|
|
44
|
+
#: The most parts of one upload.
|
|
45
|
+
MAX_PARTS = 10000
|
|
46
|
+
#: The largest object.
|
|
47
|
+
MAX_OBJECT_SIZE = 5 << 40
|
|
48
|
+
#: The largest object one request may carry.
|
|
49
|
+
MAX_PUT_SIZE = 5 << 30
|
|
50
|
+
#: Part size of uploads in parts and of parallel download ranges.
|
|
51
|
+
DEFAULT_PART_SIZE = 16 << 20
|
|
52
|
+
#: Larger uploads go in parts.
|
|
53
|
+
MULTIPART_THRESHOLD = 16 << 20
|
|
54
|
+
#: Parts or ranges moved at once.
|
|
55
|
+
DEFAULT_CONCURRENCY = 8
|
|
56
|
+
|
|
57
|
+
META_PREFIX = "X-Teepin-Meta-"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RetryMode(Enum):
|
|
61
|
+
"""When a failed request may be sent again."""
|
|
62
|
+
|
|
63
|
+
#: The request can be repeated with the same effect (reads, PUTs that
|
|
64
|
+
#: replace, deletes).
|
|
65
|
+
SAFE = "safe"
|
|
66
|
+
#: Only when the server certainly did not act on it: it never arrived, or
|
|
67
|
+
#: the server said it was busy and did nothing.
|
|
68
|
+
UNSENT = "unsent"
|
|
69
|
+
#: Teepin Inference's reads (models, attestation): 429, any 5xx and any
|
|
70
|
+
#: connection error, with backoff from 0.5 s up to 8 s and Retry-After
|
|
71
|
+
#: honoured up to 30 s.
|
|
72
|
+
INFERENCE = "inference"
|
|
73
|
+
#: A Teepin Inference chat request, which the server bills once it has the
|
|
74
|
+
#: model's answer, even if the client has gone: sent again only when it
|
|
75
|
+
#: certainly did not bill. That is a 429; Teepin's own 500 internal_error,
|
|
76
|
+
#: 502 upstream_error, 503 model_unavailable or billing_unavailable (told
|
|
77
|
+
#: by its OpenAI-style error body, unlike a proxy's 502 page); or a
|
|
78
|
+
#: connection that was never opened. Never after a timeout or a connection
|
|
79
|
+
#: lost once the request was sent, and never once a stream has started.
|
|
80
|
+
#: Backoff as INFERENCE.
|
|
81
|
+
INFERENCE_CHAT = "inference_chat"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
#: The 5xx answers of Teepin Inference that come before any billing.
|
|
85
|
+
_UNBILLED_5XX = {
|
|
86
|
+
500: ("internal_error",),
|
|
87
|
+
502: ("upstream_error",),
|
|
88
|
+
503: ("model_unavailable", "billing_unavailable"),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def retryable_status(status: int, mode: RetryMode) -> bool:
|
|
93
|
+
"""Whether an error status is worth another attempt. For INFERENCE_CHAT
|
|
94
|
+
only a 429 is, by its status alone; see retryable_error for the 5xx."""
|
|
95
|
+
if mode is RetryMode.INFERENCE:
|
|
96
|
+
return status in (429, 500, 502, 503, 504)
|
|
97
|
+
if mode is RetryMode.INFERENCE_CHAT:
|
|
98
|
+
return status == 429
|
|
99
|
+
if status in (429, 503):
|
|
100
|
+
return True # busy, or credit could not be checked: nothing was done
|
|
101
|
+
if status == 502:
|
|
102
|
+
return True # the storage backend failed; the API says nothing was stored
|
|
103
|
+
if status in (500, 504):
|
|
104
|
+
return mode is RetryMode.SAFE
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def retryable_error(err: BaseException, mode: RetryMode) -> bool:
|
|
109
|
+
"""Whether a failed attempt is worth another one."""
|
|
110
|
+
if isinstance(err, APIConnectionError):
|
|
111
|
+
return mode in (RetryMode.SAFE, RetryMode.INFERENCE) or bool(getattr(err, "unsent", False))
|
|
112
|
+
if isinstance(err, APIError):
|
|
113
|
+
if mode is RetryMode.INFERENCE_CHAT and err.status_code in _UNBILLED_5XX:
|
|
114
|
+
# Only Teepin's own answer says nothing was billed; a proxy's 502
|
|
115
|
+
# page may hide a task that died after billing.
|
|
116
|
+
return err._inference_envelope and err.code in _UNBILLED_5XX[err.status_code]
|
|
117
|
+
return retryable_status(err.status_code, mode)
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def backoff(attempt: int, err: Optional[BaseException] = None, mode: RetryMode = RetryMode.SAFE) -> float:
|
|
122
|
+
"""Seconds to wait before attempt+1: the server's Retry-After when it sent
|
|
123
|
+
one (at most a minute), else exponential with full jitter. Inference waits
|
|
124
|
+
0.5 s doubling up to 8 s, and at most 30 s for a Retry-After."""
|
|
125
|
+
if mode in (RetryMode.INFERENCE, RetryMode.INFERENCE_CHAT):
|
|
126
|
+
if isinstance(err, APIError) and err.retry_after:
|
|
127
|
+
return min(err.retry_after, 30.0)
|
|
128
|
+
return random.uniform(0, min(0.5 * (2**attempt), 8.0))
|
|
129
|
+
if isinstance(err, APIError) and err.retry_after:
|
|
130
|
+
return min(err.retry_after, 60.0)
|
|
131
|
+
ceiling = min(0.2 * (2**attempt), 10.0)
|
|
132
|
+
return random.uniform(0, ceiling) + 0.05
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def decode_error(status: int, headers: Mapping[str, str], body: bytes) -> APIError:
|
|
136
|
+
"""The APIError for an error answer: ``{error, code}``, or Teepin Inference's
|
|
137
|
+
OpenAI-style ``{"error": {message, type, code}}``, with the request id,
|
|
138
|
+
Retry-After and, for storage_on_hold, delete_after."""
|
|
139
|
+
retry_after: Optional[float] = None
|
|
140
|
+
raw = headers.get("Retry-After") or headers.get("retry-after")
|
|
141
|
+
if raw:
|
|
142
|
+
try:
|
|
143
|
+
secs = int(raw.strip())
|
|
144
|
+
if secs > 0:
|
|
145
|
+
retry_after = float(secs)
|
|
146
|
+
except ValueError:
|
|
147
|
+
pass
|
|
148
|
+
request_id = headers.get("X-Request-ID") or headers.get("x-request-id")
|
|
149
|
+
message, code, error_type, delete_after = "", "", "", None
|
|
150
|
+
try:
|
|
151
|
+
data = json.loads(body[: 64 << 10] or b"null")
|
|
152
|
+
except ValueError:
|
|
153
|
+
data = None
|
|
154
|
+
if isinstance(data, dict) and isinstance(data.get("error"), dict):
|
|
155
|
+
envelope = data["error"]
|
|
156
|
+
message = str(envelope.get("message") or "")
|
|
157
|
+
code = str(envelope.get("code") or "")
|
|
158
|
+
error_type = str(envelope.get("type") or "")
|
|
159
|
+
elif isinstance(data, dict) and (data.get("error") or data.get("code")):
|
|
160
|
+
message = str(data.get("error") or "")
|
|
161
|
+
code = str(data.get("code") or "")
|
|
162
|
+
if data.get("delete_after"):
|
|
163
|
+
try:
|
|
164
|
+
delete_after = datetime.fromisoformat(str(data["delete_after"]).replace("Z", "+00:00"))
|
|
165
|
+
except ValueError:
|
|
166
|
+
pass
|
|
167
|
+
else:
|
|
168
|
+
message = body[: 64 << 10].decode("utf-8", "replace").strip()
|
|
169
|
+
if not message:
|
|
170
|
+
message = f"HTTP {status}"
|
|
171
|
+
err = api_error(status, message, code, request_id, retry_after, delete_after, error_type)
|
|
172
|
+
err._inference_envelope = isinstance(data, dict) and isinstance(data.get("error"), dict)
|
|
173
|
+
return err
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def bucket_path(bucket: str) -> str:
|
|
177
|
+
return "/v1/storage/buckets/" + quote(bucket, safe="")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def upload_path(bucket: str, upload_id: str) -> str:
|
|
181
|
+
return bucket_path(bucket) + "/uploads/" + quote(upload_id, safe="")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def quote_etag(etag: str) -> str:
|
|
185
|
+
if etag == "*" or etag.startswith('"') or etag.startswith('W/"'):
|
|
186
|
+
return etag
|
|
187
|
+
return f'"{etag}"'
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def unquote_etag(etag: Optional[str]) -> str:
|
|
191
|
+
if not etag:
|
|
192
|
+
return ""
|
|
193
|
+
etag = etag.strip()
|
|
194
|
+
if etag.startswith("W/"):
|
|
195
|
+
etag = etag[2:]
|
|
196
|
+
return etag.strip('"')
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def put_headers(
|
|
200
|
+
content_type: Optional[str] = None,
|
|
201
|
+
metadata: Optional[Mapping[str, str]] = None,
|
|
202
|
+
if_match: Optional[str] = None,
|
|
203
|
+
if_none_match: bool = False,
|
|
204
|
+
) -> Dict[str, str]:
|
|
205
|
+
"""Headers of a one-request upload."""
|
|
206
|
+
h: Dict[str, str] = {}
|
|
207
|
+
if content_type:
|
|
208
|
+
h["Content-Type"] = content_type
|
|
209
|
+
for name, value in (metadata or {}).items():
|
|
210
|
+
h[META_PREFIX + name] = value
|
|
211
|
+
if if_match:
|
|
212
|
+
h["If-Match"] = quote_etag(if_match)
|
|
213
|
+
if if_none_match:
|
|
214
|
+
h["If-None-Match"] = "*"
|
|
215
|
+
return h
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def range_header(start: int, end: Optional[int]) -> str:
|
|
219
|
+
"""``bytes=start-end`` (inclusive); end None is to the end."""
|
|
220
|
+
return f"bytes={start}-" if end is None else f"bytes={start}-{end}"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def total_size(headers: Mapping[str, str], fallback: int) -> int:
|
|
224
|
+
"""The whole object's size from Content-Range (``bytes 0-99/1234``)."""
|
|
225
|
+
cr = headers.get("Content-Range") or headers.get("content-range") or ""
|
|
226
|
+
if "/" in cr:
|
|
227
|
+
try:
|
|
228
|
+
return int(cr.rsplit("/", 1)[1])
|
|
229
|
+
except ValueError:
|
|
230
|
+
pass
|
|
231
|
+
return fallback
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def guess_content_type(path: Union[str, os.PathLike]) -> Optional[str]:
|
|
235
|
+
return mimetypes.guess_type(str(path))[0]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def part_size_for(size: int, want: int = 0) -> int:
|
|
239
|
+
"""The part size an upload of ``size`` bytes uses when ``want`` (0 for the
|
|
240
|
+
default) is asked for: at least 5 MiB, doubled until the parts fit in
|
|
241
|
+
10,000."""
|
|
242
|
+
p = want if want > 0 else DEFAULT_PART_SIZE
|
|
243
|
+
p = max(p, MIN_PART_SIZE)
|
|
244
|
+
while p < MAX_PART_SIZE and (size + p - 1) // p > MAX_PARTS:
|
|
245
|
+
p *= 2
|
|
246
|
+
return min(p, MAX_PART_SIZE)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def part_spans(size: int, part_size: int) -> List[Tuple[int, int, int]]:
|
|
250
|
+
"""``(number, offset, length)`` of each part, numbered from 1."""
|
|
251
|
+
out = []
|
|
252
|
+
num, off = 1, 0
|
|
253
|
+
while off < size:
|
|
254
|
+
n = min(part_size, size - off)
|
|
255
|
+
out.append((num, off, n))
|
|
256
|
+
num += 1
|
|
257
|
+
off += n
|
|
258
|
+
return out
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def composite_etag(part_sha256s: Iterable[bytes]) -> str:
|
|
262
|
+
"""``<sha256 of the parts' sha256s>-<parts>``: the ETag of an upload in parts."""
|
|
263
|
+
h = hashlib.sha256()
|
|
264
|
+
n = 0
|
|
265
|
+
for digest in part_sha256s:
|
|
266
|
+
h.update(digest)
|
|
267
|
+
n += 1
|
|
268
|
+
return f"{h.hexdigest()}-{n}"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def compute_etag(
|
|
272
|
+
source: Union[str, os.PathLike, bytes, bytearray, memoryview, IO[bytes]],
|
|
273
|
+
part_size: int = 0,
|
|
274
|
+
parts: int = 0,
|
|
275
|
+
) -> str:
|
|
276
|
+
"""The ETag Teepin gives these bytes: the SHA-256 when ``parts`` is 0, or for
|
|
277
|
+
an upload in parts of ``part_size`` bytes the SHA-256 of the parts'
|
|
278
|
+
SHA-256s with ``-<parts>``. Compare it with ``Object.etag`` to tell whether
|
|
279
|
+
a local file and an object hold the same bytes.
|
|
280
|
+
|
|
281
|
+
``source`` is a path, bytes, or a binary file object read from its start.
|
|
282
|
+
"""
|
|
283
|
+
if isinstance(source, (bytes, bytearray, memoryview)):
|
|
284
|
+
data = bytes(source)
|
|
285
|
+
if parts == 0:
|
|
286
|
+
return hashlib.sha256(data).hexdigest()
|
|
287
|
+
_check_parts(len(data), part_size, parts)
|
|
288
|
+
return composite_etag(hashlib.sha256(data[o : o + n]).digest() for _, o, n in part_spans(len(data), part_size))
|
|
289
|
+
if isinstance(source, (str, os.PathLike)):
|
|
290
|
+
with open(source, "rb") as f:
|
|
291
|
+
return compute_etag(f, part_size, parts)
|
|
292
|
+
f = source
|
|
293
|
+
f.seek(0, os.SEEK_END)
|
|
294
|
+
size = f.tell()
|
|
295
|
+
if parts == 0:
|
|
296
|
+
f.seek(0)
|
|
297
|
+
h = hashlib.sha256()
|
|
298
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
299
|
+
h.update(chunk)
|
|
300
|
+
return h.hexdigest()
|
|
301
|
+
_check_parts(size, part_size, parts)
|
|
302
|
+
digests = []
|
|
303
|
+
for _, off, n in part_spans(size, part_size):
|
|
304
|
+
f.seek(off)
|
|
305
|
+
h = hashlib.sha256()
|
|
306
|
+
left = n
|
|
307
|
+
while left:
|
|
308
|
+
chunk = f.read(min(left, 1 << 20))
|
|
309
|
+
if not chunk:
|
|
310
|
+
raise ValueError("the file ended early")
|
|
311
|
+
h.update(chunk)
|
|
312
|
+
left -= len(chunk)
|
|
313
|
+
digests.append(h.digest())
|
|
314
|
+
return composite_etag(digests)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _check_parts(size: int, part_size: int, parts: int) -> None:
|
|
318
|
+
if part_size <= 0 or (size + part_size - 1) // part_size != parts:
|
|
319
|
+
raise ValueError(f"{size} bytes in parts of {part_size} is not {parts} parts")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def absolute(base_url: str, path: str) -> str:
|
|
323
|
+
"""A signed link the API returned as a path, made absolute."""
|
|
324
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
325
|
+
return path
|
|
326
|
+
return base_url + path
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class Progress:
|
|
330
|
+
"""Adds up bytes moved and tells the caller's callback (done, total).
|
|
331
|
+
Thread safe; an attempt that failed can be taken back."""
|
|
332
|
+
|
|
333
|
+
def __init__(self, total: int, fn: Optional[ProgressFunc]) -> None:
|
|
334
|
+
self.total = total
|
|
335
|
+
self.done = 0
|
|
336
|
+
self.fn = fn
|
|
337
|
+
self._lock = threading.Lock()
|
|
338
|
+
|
|
339
|
+
def add(self, n: int) -> None:
|
|
340
|
+
if not self.fn or n == 0:
|
|
341
|
+
return
|
|
342
|
+
with self._lock:
|
|
343
|
+
self.done += n
|
|
344
|
+
self.fn(self.done, self.total)
|