nerdstack-ark 1.0.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.
- ark_py/__init__.py +31 -0
- ark_py/_shared.py +182 -0
- ark_py/async_client.py +543 -0
- ark_py/errors.py +76 -0
- ark_py/models.py +108 -0
- ark_py/py.typed +1 -0
- ark_py/s3.py +38 -0
- ark_py/sync.py +381 -0
- nerdstack_ark-1.0.0.dist-info/METADATA +187 -0
- nerdstack_ark-1.0.0.dist-info/RECORD +12 -0
- nerdstack_ark-1.0.0.dist-info/WHEEL +4 -0
- nerdstack_ark-1.0.0.dist-info/licenses/LICENSE +21 -0
ark_py/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Official Python SDK for Ark storage."""
|
|
2
|
+
|
|
3
|
+
from .async_client import AsyncArk
|
|
4
|
+
from .errors import ArkError
|
|
5
|
+
from .models import (
|
|
6
|
+
ArkFile,
|
|
7
|
+
ArkFolder,
|
|
8
|
+
ArkUsage,
|
|
9
|
+
ClientSession,
|
|
10
|
+
FilePage,
|
|
11
|
+
ImageOptions,
|
|
12
|
+
StorageUsage,
|
|
13
|
+
)
|
|
14
|
+
from .s3 import create_s3_client
|
|
15
|
+
from .sync import Ark
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Ark",
|
|
19
|
+
"ArkError",
|
|
20
|
+
"ArkFile",
|
|
21
|
+
"ArkFolder",
|
|
22
|
+
"ArkUsage",
|
|
23
|
+
"AsyncArk",
|
|
24
|
+
"ClientSession",
|
|
25
|
+
"FilePage",
|
|
26
|
+
"ImageOptions",
|
|
27
|
+
"StorageUsage",
|
|
28
|
+
"create_s3_client",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
__version__ = "1.0.0"
|
ark_py/_shared.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import mimetypes
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Iterable, Iterator, Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, BinaryIO
|
|
9
|
+
from urllib.parse import quote, urlencode
|
|
10
|
+
|
|
11
|
+
from .errors import invalid_argument
|
|
12
|
+
from .models import ClientSession, ImageOptions
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE_URL = "https://ark.nerdstackgrp.com"
|
|
15
|
+
DEFAULT_CONTENT_TYPE = "application/octet-stream"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def api_url(base_url: str, version: str, path: str) -> str:
|
|
19
|
+
return f"{base_url.rstrip('/')}/api/{quote(version, safe='')}{path}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def segment(value: str) -> str:
|
|
23
|
+
return quote(value, safe="")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def image_url(base_url: str, version: str, asset_id: str, options: ImageOptions) -> str:
|
|
27
|
+
query: dict[str, str] = {}
|
|
28
|
+
if options.width is not None:
|
|
29
|
+
query["width"] = str(options.width)
|
|
30
|
+
if options.height is not None:
|
|
31
|
+
query["height"] = str(options.height)
|
|
32
|
+
if options.quality is not None:
|
|
33
|
+
query["quality"] = str(options.quality)
|
|
34
|
+
if options.format != "original":
|
|
35
|
+
query["format"] = options.format
|
|
36
|
+
if options.thumbnail:
|
|
37
|
+
query["thumbnail"] = "1"
|
|
38
|
+
if options.watermark:
|
|
39
|
+
query["watermark"] = "1"
|
|
40
|
+
suffix = f"?{urlencode(query)}" if query else ""
|
|
41
|
+
return api_url(base_url, version, f"/assets/{segment(asset_id)}/image{suffix}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_client_session(value: Mapping[str, Any]) -> ClientSession:
|
|
45
|
+
raw_scopes = value.get("scopes")
|
|
46
|
+
scopes = tuple(str(scope) for scope in raw_scopes) if isinstance(raw_scopes, list) else ()
|
|
47
|
+
return ClientSession(
|
|
48
|
+
token=str(value["token"]),
|
|
49
|
+
expires_at=str(value["expiresAt"]),
|
|
50
|
+
expires_in_seconds=int(value["expiresInSeconds"]),
|
|
51
|
+
scopes=scopes,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class UploadSource:
|
|
57
|
+
size: int
|
|
58
|
+
filename: str
|
|
59
|
+
content_type: str
|
|
60
|
+
path: Path | None
|
|
61
|
+
stream: BinaryIO | None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def resolve_upload_source(
|
|
65
|
+
source: str | os.PathLike[str] | BinaryIO,
|
|
66
|
+
*,
|
|
67
|
+
size: int | None,
|
|
68
|
+
filename: str | None,
|
|
69
|
+
content_type: str | None,
|
|
70
|
+
) -> UploadSource:
|
|
71
|
+
if isinstance(source, (str, os.PathLike)):
|
|
72
|
+
path = Path(source)
|
|
73
|
+
resolved_size = path.stat().st_size
|
|
74
|
+
resolved_filename = filename or path.name
|
|
75
|
+
guessed_type = mimetypes.guess_type(resolved_filename)[0]
|
|
76
|
+
resolved_type = content_type or guessed_type or DEFAULT_CONTENT_TYPE
|
|
77
|
+
validate_size(resolved_size)
|
|
78
|
+
return UploadSource(resolved_size, resolved_filename, resolved_type, path, None)
|
|
79
|
+
|
|
80
|
+
resolved_size = size if size is not None else infer_stream_size(source)
|
|
81
|
+
validate_size(resolved_size)
|
|
82
|
+
source_name = getattr(source, "name", None)
|
|
83
|
+
stream_filename = filename or (Path(source_name).name if isinstance(source_name, str) else None)
|
|
84
|
+
if not stream_filename:
|
|
85
|
+
raise invalid_argument("filename is required for stream uploads")
|
|
86
|
+
resolved_type = content_type or mimetypes.guess_type(stream_filename)[0] or DEFAULT_CONTENT_TYPE
|
|
87
|
+
return UploadSource(resolved_size, stream_filename, resolved_type, None, source)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def infer_stream_size(stream: BinaryIO) -> int:
|
|
91
|
+
if not stream.seekable():
|
|
92
|
+
raise invalid_argument("size is required for non-seekable stream uploads")
|
|
93
|
+
position = stream.tell()
|
|
94
|
+
stream.seek(0, os.SEEK_END)
|
|
95
|
+
end = stream.tell()
|
|
96
|
+
stream.seek(position)
|
|
97
|
+
return end - position
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def validate_size(size: int) -> None:
|
|
101
|
+
if isinstance(size, bool) or not isinstance(size, int) or size <= 0:
|
|
102
|
+
raise invalid_argument("upload size must be a positive integer")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def read_exact(stream: BinaryIO, size: int) -> bytes:
|
|
106
|
+
chunks: list[bytes] = []
|
|
107
|
+
remaining = size
|
|
108
|
+
while remaining:
|
|
109
|
+
chunk = stream.read(remaining)
|
|
110
|
+
if not chunk:
|
|
111
|
+
actual = size - remaining
|
|
112
|
+
raise invalid_argument(f"upload stream ended after {actual} bytes; expected {size}")
|
|
113
|
+
chunks.append(chunk)
|
|
114
|
+
remaining -= len(chunk)
|
|
115
|
+
return b"".join(chunks)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def ensure_stream_complete(stream: BinaryIO, declared_size: int) -> None:
|
|
119
|
+
if stream.read(1):
|
|
120
|
+
raise invalid_argument(f"upload stream produced more than {declared_size} bytes")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def iter_exact(stream: BinaryIO, size: int, chunk_size: int = 64 * 1024) -> Iterator[bytes]:
|
|
124
|
+
sent = 0
|
|
125
|
+
while sent < size:
|
|
126
|
+
chunk = stream.read(min(chunk_size, size - sent))
|
|
127
|
+
if not chunk:
|
|
128
|
+
raise invalid_argument(f"upload stream ended after {sent} bytes; expected {size}")
|
|
129
|
+
sent += len(chunk)
|
|
130
|
+
yield chunk
|
|
131
|
+
ensure_stream_complete(stream, size)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def iter_file_range(
|
|
135
|
+
path: Path,
|
|
136
|
+
start: int,
|
|
137
|
+
size: int,
|
|
138
|
+
chunk_size: int = 64 * 1024,
|
|
139
|
+
) -> Iterator[bytes]:
|
|
140
|
+
with path.open("rb") as stream:
|
|
141
|
+
stream.seek(start)
|
|
142
|
+
remaining = size
|
|
143
|
+
while remaining:
|
|
144
|
+
chunk = stream.read(min(chunk_size, remaining))
|
|
145
|
+
if not chunk:
|
|
146
|
+
raise invalid_argument(f"file ended before the expected {size}-byte range")
|
|
147
|
+
remaining -= len(chunk)
|
|
148
|
+
yield chunk
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def upload_payload(
|
|
152
|
+
filename: str,
|
|
153
|
+
size: int,
|
|
154
|
+
content_type: str,
|
|
155
|
+
folder_id: str | None,
|
|
156
|
+
metadata: Mapping[str, Any] | None,
|
|
157
|
+
) -> dict[str, Any]:
|
|
158
|
+
payload: dict[str, Any] = {
|
|
159
|
+
"filename": filename,
|
|
160
|
+
"size": size,
|
|
161
|
+
"mimeType": content_type,
|
|
162
|
+
}
|
|
163
|
+
if folder_id is not None:
|
|
164
|
+
payload["folderId"] = folder_id
|
|
165
|
+
if metadata is not None:
|
|
166
|
+
payload["metadata"] = dict(metadata)
|
|
167
|
+
return payload
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def query_string(values: Mapping[str, object | None]) -> str:
|
|
171
|
+
filtered = {key: value for key, value in values.items() if value is not None}
|
|
172
|
+
return f"?{urlencode(filtered)}" if filtered else ""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def sorted_parts(parts: Iterable[dict[str, object]]) -> list[dict[str, object]]:
|
|
176
|
+
def part_number(part: dict[str, object]) -> int:
|
|
177
|
+
value = part["partNumber"]
|
|
178
|
+
if not isinstance(value, int):
|
|
179
|
+
raise invalid_argument("multipart partNumber must be an integer")
|
|
180
|
+
return value
|
|
181
|
+
|
|
182
|
+
return sorted(parts, key=part_number)
|