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 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)