elaunira-r2index 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.
- elaunira/__init__.py +1 -0
- elaunira/r2index/__init__.py +118 -0
- elaunira/r2index/async_client.py +503 -0
- elaunira/r2index/async_uploader.py +149 -0
- elaunira/r2index/checksums.py +127 -0
- elaunira/r2index/client.py +502 -0
- elaunira/r2index/exceptions.py +40 -0
- elaunira/r2index/models.py +204 -0
- elaunira/r2index/uploader.py +147 -0
- elaunira_r2index-0.1.0.dist-info/METADATA +101 -0
- elaunira_r2index-0.1.0.dist-info/RECORD +12 -0
- elaunira_r2index-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""Synchronous R2Index API client."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .checksums import compute_checksums
|
|
11
|
+
from .exceptions import (
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
ConflictError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
R2IndexError,
|
|
16
|
+
ValidationError,
|
|
17
|
+
)
|
|
18
|
+
from .models import (
|
|
19
|
+
CleanupResponse,
|
|
20
|
+
DownloadRecord,
|
|
21
|
+
DownloadRecordRequest,
|
|
22
|
+
DownloadsByIpResponse,
|
|
23
|
+
FileCreateRequest,
|
|
24
|
+
FileListResponse,
|
|
25
|
+
FileRecord,
|
|
26
|
+
FileUpdateRequest,
|
|
27
|
+
HealthResponse,
|
|
28
|
+
IndexEntry,
|
|
29
|
+
RemoteTuple,
|
|
30
|
+
SummaryResponse,
|
|
31
|
+
TimeseriesResponse,
|
|
32
|
+
UserAgentsResponse,
|
|
33
|
+
)
|
|
34
|
+
from .uploader import R2Config, R2Uploader
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class R2IndexClient:
|
|
38
|
+
"""Synchronous client for the r2index API."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
api_url: str,
|
|
43
|
+
api_token: str,
|
|
44
|
+
r2_config: R2Config | None = None,
|
|
45
|
+
timeout: float = 30.0,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""
|
|
48
|
+
Initialize the R2Index client.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
api_url: Base URL of the r2index API.
|
|
52
|
+
api_token: Bearer token for authentication.
|
|
53
|
+
r2_config: Optional R2 configuration for upload operations.
|
|
54
|
+
timeout: Request timeout in seconds.
|
|
55
|
+
"""
|
|
56
|
+
self.api_url = api_url.rstrip("/")
|
|
57
|
+
self._token = api_token
|
|
58
|
+
self._timeout = timeout
|
|
59
|
+
self._r2_config = r2_config
|
|
60
|
+
self._uploader: R2Uploader | None = None
|
|
61
|
+
|
|
62
|
+
self._client = httpx.Client(
|
|
63
|
+
base_url=self.api_url,
|
|
64
|
+
headers={"Authorization": f"Bearer {api_token}"},
|
|
65
|
+
timeout=timeout,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def __enter__(self) -> "R2IndexClient":
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
72
|
+
self.close()
|
|
73
|
+
|
|
74
|
+
def close(self) -> None:
|
|
75
|
+
"""Close the HTTP client."""
|
|
76
|
+
self._client.close()
|
|
77
|
+
|
|
78
|
+
def _get_uploader(self) -> R2Uploader:
|
|
79
|
+
"""Get or create the R2 uploader."""
|
|
80
|
+
if self._r2_config is None:
|
|
81
|
+
raise R2IndexError("R2 configuration required for upload operations")
|
|
82
|
+
if self._uploader is None:
|
|
83
|
+
self._uploader = R2Uploader(self._r2_config)
|
|
84
|
+
return self._uploader
|
|
85
|
+
|
|
86
|
+
def _handle_response(self, response: httpx.Response) -> Any:
|
|
87
|
+
"""Handle API response and raise appropriate exceptions."""
|
|
88
|
+
if response.status_code == 200 or response.status_code == 201:
|
|
89
|
+
return response.json()
|
|
90
|
+
|
|
91
|
+
status = response.status_code
|
|
92
|
+
try:
|
|
93
|
+
error_data = response.json()
|
|
94
|
+
message = error_data.get("error", response.text)
|
|
95
|
+
except Exception:
|
|
96
|
+
message = response.text
|
|
97
|
+
|
|
98
|
+
if status == 401 or status == 403:
|
|
99
|
+
raise AuthenticationError(message, status)
|
|
100
|
+
elif status == 404:
|
|
101
|
+
raise NotFoundError(message, status)
|
|
102
|
+
elif status == 400:
|
|
103
|
+
raise ValidationError(message, status)
|
|
104
|
+
elif status == 409:
|
|
105
|
+
raise ConflictError(message, status)
|
|
106
|
+
else:
|
|
107
|
+
raise R2IndexError(message, status)
|
|
108
|
+
|
|
109
|
+
# File Operations
|
|
110
|
+
|
|
111
|
+
def list_files(
|
|
112
|
+
self,
|
|
113
|
+
category: str | None = None,
|
|
114
|
+
entity: str | None = None,
|
|
115
|
+
tags: list[str] | None = None,
|
|
116
|
+
page: int | None = None,
|
|
117
|
+
page_size: int | None = None,
|
|
118
|
+
) -> FileListResponse:
|
|
119
|
+
"""
|
|
120
|
+
List files with optional filters.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
category: Filter by category.
|
|
124
|
+
entity: Filter by entity.
|
|
125
|
+
tags: Filter by tags.
|
|
126
|
+
page: Page number (1-indexed).
|
|
127
|
+
page_size: Number of items per page.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
FileListResponse with files and pagination info.
|
|
131
|
+
"""
|
|
132
|
+
params: dict[str, Any] = {}
|
|
133
|
+
if category:
|
|
134
|
+
params["category"] = category
|
|
135
|
+
if entity:
|
|
136
|
+
params["entity"] = entity
|
|
137
|
+
if tags:
|
|
138
|
+
params["tags"] = ",".join(tags)
|
|
139
|
+
if page:
|
|
140
|
+
params["page"] = page
|
|
141
|
+
if page_size:
|
|
142
|
+
params["pageSize"] = page_size
|
|
143
|
+
|
|
144
|
+
response = self._client.get("/files", params=params)
|
|
145
|
+
data = self._handle_response(response)
|
|
146
|
+
return FileListResponse.model_validate(data)
|
|
147
|
+
|
|
148
|
+
def create_file(self, data: FileCreateRequest) -> FileRecord:
|
|
149
|
+
"""
|
|
150
|
+
Create or upsert a file record.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
data: File creation request data.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
The created or updated FileRecord.
|
|
157
|
+
"""
|
|
158
|
+
response = self._client.post("/files", json=data.model_dump(by_alias=True))
|
|
159
|
+
result = self._handle_response(response)
|
|
160
|
+
return FileRecord.model_validate(result)
|
|
161
|
+
|
|
162
|
+
def get_file(self, file_id: str) -> FileRecord:
|
|
163
|
+
"""
|
|
164
|
+
Get a file by ID.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
file_id: The file ID.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
The FileRecord.
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
NotFoundError: If the file is not found.
|
|
174
|
+
"""
|
|
175
|
+
response = self._client.get(f"/files/{file_id}")
|
|
176
|
+
data = self._handle_response(response)
|
|
177
|
+
return FileRecord.model_validate(data)
|
|
178
|
+
|
|
179
|
+
def update_file(self, file_id: str, data: FileUpdateRequest) -> FileRecord:
|
|
180
|
+
"""
|
|
181
|
+
Update a file record.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
file_id: The file ID to update.
|
|
185
|
+
data: Fields to update.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
The updated FileRecord.
|
|
189
|
+
"""
|
|
190
|
+
response = self._client.put(
|
|
191
|
+
f"/files/{file_id}",
|
|
192
|
+
json=data.model_dump(exclude_none=True, by_alias=True),
|
|
193
|
+
)
|
|
194
|
+
result = self._handle_response(response)
|
|
195
|
+
return FileRecord.model_validate(result)
|
|
196
|
+
|
|
197
|
+
def delete_file(self, file_id: str) -> None:
|
|
198
|
+
"""
|
|
199
|
+
Delete a file by ID.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
file_id: The file ID to delete.
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
NotFoundError: If the file is not found.
|
|
206
|
+
"""
|
|
207
|
+
response = self._client.delete(f"/files/{file_id}")
|
|
208
|
+
self._handle_response(response)
|
|
209
|
+
|
|
210
|
+
def delete_file_by_tuple(self, remote_tuple: RemoteTuple) -> None:
|
|
211
|
+
"""
|
|
212
|
+
Delete a file by remote tuple.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
remote_tuple: The remote path, filename, and version.
|
|
216
|
+
|
|
217
|
+
Raises:
|
|
218
|
+
NotFoundError: If the file is not found.
|
|
219
|
+
"""
|
|
220
|
+
params = {
|
|
221
|
+
"remotePath": remote_tuple.remote_path,
|
|
222
|
+
"remoteFilename": remote_tuple.remote_filename,
|
|
223
|
+
"remoteVersion": remote_tuple.remote_version,
|
|
224
|
+
}
|
|
225
|
+
response = self._client.delete("/files", params=params)
|
|
226
|
+
self._handle_response(response)
|
|
227
|
+
|
|
228
|
+
def get_index(
|
|
229
|
+
self,
|
|
230
|
+
category: str | None = None,
|
|
231
|
+
entity: str | None = None,
|
|
232
|
+
tags: list[str] | None = None,
|
|
233
|
+
) -> list[IndexEntry]:
|
|
234
|
+
"""
|
|
235
|
+
Get file index (lightweight listing).
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
category: Filter by category.
|
|
239
|
+
entity: Filter by entity.
|
|
240
|
+
tags: Filter by tags.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
List of IndexEntry objects.
|
|
244
|
+
"""
|
|
245
|
+
params: dict[str, Any] = {}
|
|
246
|
+
if category:
|
|
247
|
+
params["category"] = category
|
|
248
|
+
if entity:
|
|
249
|
+
params["entity"] = entity
|
|
250
|
+
if tags:
|
|
251
|
+
params["tags"] = ",".join(tags)
|
|
252
|
+
|
|
253
|
+
response = self._client.get("/files/index", params=params)
|
|
254
|
+
data = self._handle_response(response)
|
|
255
|
+
return [IndexEntry.model_validate(item) for item in data]
|
|
256
|
+
|
|
257
|
+
# Download Tracking
|
|
258
|
+
|
|
259
|
+
def record_download(self, data: DownloadRecordRequest) -> DownloadRecord:
|
|
260
|
+
"""
|
|
261
|
+
Record a file download.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
data: Download record data.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
The created DownloadRecord.
|
|
268
|
+
"""
|
|
269
|
+
response = self._client.post("/downloads", json=data.model_dump(by_alias=True))
|
|
270
|
+
result = self._handle_response(response)
|
|
271
|
+
return DownloadRecord.model_validate(result)
|
|
272
|
+
|
|
273
|
+
# Analytics
|
|
274
|
+
|
|
275
|
+
def get_timeseries(
|
|
276
|
+
self,
|
|
277
|
+
start: datetime,
|
|
278
|
+
end: datetime,
|
|
279
|
+
granularity: str = "day",
|
|
280
|
+
file_id: str | None = None,
|
|
281
|
+
category: str | None = None,
|
|
282
|
+
entity: str | None = None,
|
|
283
|
+
) -> TimeseriesResponse:
|
|
284
|
+
"""
|
|
285
|
+
Get download timeseries analytics.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
start: Start datetime.
|
|
289
|
+
end: End datetime.
|
|
290
|
+
granularity: Time granularity (hour, day, week, month).
|
|
291
|
+
file_id: Optional file ID filter.
|
|
292
|
+
category: Optional category filter.
|
|
293
|
+
entity: Optional entity filter.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
TimeseriesResponse with data points.
|
|
297
|
+
"""
|
|
298
|
+
params: dict[str, Any] = {
|
|
299
|
+
"start": start.isoformat(),
|
|
300
|
+
"end": end.isoformat(),
|
|
301
|
+
"granularity": granularity,
|
|
302
|
+
}
|
|
303
|
+
if file_id:
|
|
304
|
+
params["fileId"] = file_id
|
|
305
|
+
if category:
|
|
306
|
+
params["category"] = category
|
|
307
|
+
if entity:
|
|
308
|
+
params["entity"] = entity
|
|
309
|
+
|
|
310
|
+
response = self._client.get("/analytics/timeseries", params=params)
|
|
311
|
+
data = self._handle_response(response)
|
|
312
|
+
return TimeseriesResponse.model_validate(data)
|
|
313
|
+
|
|
314
|
+
def get_summary(
|
|
315
|
+
self,
|
|
316
|
+
start: datetime,
|
|
317
|
+
end: datetime,
|
|
318
|
+
file_id: str | None = None,
|
|
319
|
+
category: str | None = None,
|
|
320
|
+
entity: str | None = None,
|
|
321
|
+
) -> SummaryResponse:
|
|
322
|
+
"""
|
|
323
|
+
Get download summary analytics.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
start: Start datetime.
|
|
327
|
+
end: End datetime.
|
|
328
|
+
file_id: Optional file ID filter.
|
|
329
|
+
category: Optional category filter.
|
|
330
|
+
entity: Optional entity filter.
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
SummaryResponse with aggregated statistics.
|
|
334
|
+
"""
|
|
335
|
+
params: dict[str, Any] = {
|
|
336
|
+
"start": start.isoformat(),
|
|
337
|
+
"end": end.isoformat(),
|
|
338
|
+
}
|
|
339
|
+
if file_id:
|
|
340
|
+
params["fileId"] = file_id
|
|
341
|
+
if category:
|
|
342
|
+
params["category"] = category
|
|
343
|
+
if entity:
|
|
344
|
+
params["entity"] = entity
|
|
345
|
+
|
|
346
|
+
response = self._client.get("/analytics/summary", params=params)
|
|
347
|
+
data = self._handle_response(response)
|
|
348
|
+
return SummaryResponse.model_validate(data)
|
|
349
|
+
|
|
350
|
+
def get_downloads_by_ip(
|
|
351
|
+
self,
|
|
352
|
+
ip_address: str,
|
|
353
|
+
start: datetime,
|
|
354
|
+
end: datetime,
|
|
355
|
+
) -> DownloadsByIpResponse:
|
|
356
|
+
"""
|
|
357
|
+
Get downloads by IP address.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
ip_address: The IP address to query.
|
|
361
|
+
start: Start datetime.
|
|
362
|
+
end: End datetime.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
DownloadsByIpResponse with download records.
|
|
366
|
+
"""
|
|
367
|
+
params = {
|
|
368
|
+
"start": start.isoformat(),
|
|
369
|
+
"end": end.isoformat(),
|
|
370
|
+
}
|
|
371
|
+
response = self._client.get(f"/analytics/by-ip/{ip_address}", params=params)
|
|
372
|
+
data = self._handle_response(response)
|
|
373
|
+
return DownloadsByIpResponse.model_validate(data)
|
|
374
|
+
|
|
375
|
+
def get_user_agents(
|
|
376
|
+
self,
|
|
377
|
+
start: datetime,
|
|
378
|
+
end: datetime,
|
|
379
|
+
) -> UserAgentsResponse:
|
|
380
|
+
"""
|
|
381
|
+
Get user agent analytics.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
start: Start datetime.
|
|
385
|
+
end: End datetime.
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
UserAgentsResponse with user agent counts.
|
|
389
|
+
"""
|
|
390
|
+
params = {
|
|
391
|
+
"start": start.isoformat(),
|
|
392
|
+
"end": end.isoformat(),
|
|
393
|
+
}
|
|
394
|
+
response = self._client.get("/analytics/user-agents", params=params)
|
|
395
|
+
data = self._handle_response(response)
|
|
396
|
+
return UserAgentsResponse.model_validate(data)
|
|
397
|
+
|
|
398
|
+
# Maintenance
|
|
399
|
+
|
|
400
|
+
def cleanup_downloads(self) -> CleanupResponse:
|
|
401
|
+
"""
|
|
402
|
+
Clean up old download records.
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
CleanupResponse with deleted count.
|
|
406
|
+
"""
|
|
407
|
+
response = self._client.post("/maintenance/cleanup-downloads")
|
|
408
|
+
data = self._handle_response(response)
|
|
409
|
+
return CleanupResponse.model_validate(data)
|
|
410
|
+
|
|
411
|
+
# Health
|
|
412
|
+
|
|
413
|
+
def health(self) -> HealthResponse:
|
|
414
|
+
"""
|
|
415
|
+
Check API health.
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
HealthResponse with status and timestamp.
|
|
419
|
+
"""
|
|
420
|
+
response = self._client.get("/health")
|
|
421
|
+
data = self._handle_response(response)
|
|
422
|
+
return HealthResponse.model_validate(data)
|
|
423
|
+
|
|
424
|
+
# High-Level Pipeline
|
|
425
|
+
|
|
426
|
+
def upload_and_register(
|
|
427
|
+
self,
|
|
428
|
+
file_path: str | Path,
|
|
429
|
+
category: str,
|
|
430
|
+
entity: str,
|
|
431
|
+
remote_path: str,
|
|
432
|
+
remote_filename: str,
|
|
433
|
+
remote_version: str,
|
|
434
|
+
name: str | None = None,
|
|
435
|
+
tags: list[str] | None = None,
|
|
436
|
+
extra: dict[str, Any] | None = None,
|
|
437
|
+
content_type: str | None = None,
|
|
438
|
+
progress_callback: Callable[[int], None] | None = None,
|
|
439
|
+
) -> FileRecord:
|
|
440
|
+
"""
|
|
441
|
+
Upload a file to R2 and register it with the r2index API.
|
|
442
|
+
|
|
443
|
+
This is a convenience method that performs the full pipeline:
|
|
444
|
+
1. Compute checksums (streaming, memory efficient)
|
|
445
|
+
2. Upload to R2 (multipart for large files)
|
|
446
|
+
3. Register with r2index API
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
file_path: Path to the file to upload.
|
|
450
|
+
category: File category.
|
|
451
|
+
entity: File entity.
|
|
452
|
+
remote_path: Remote path in R2 (e.g., "/data/files").
|
|
453
|
+
remote_filename: Remote filename in R2.
|
|
454
|
+
remote_version: Version identifier.
|
|
455
|
+
name: Optional display name.
|
|
456
|
+
tags: Optional list of tags.
|
|
457
|
+
extra: Optional extra metadata.
|
|
458
|
+
content_type: Optional content type for R2.
|
|
459
|
+
progress_callback: Optional callback for upload progress.
|
|
460
|
+
|
|
461
|
+
Returns:
|
|
462
|
+
The created FileRecord.
|
|
463
|
+
|
|
464
|
+
Raises:
|
|
465
|
+
R2IndexError: If R2 config is not provided.
|
|
466
|
+
UploadError: If upload fails.
|
|
467
|
+
"""
|
|
468
|
+
file_path = Path(file_path)
|
|
469
|
+
uploader = self._get_uploader()
|
|
470
|
+
|
|
471
|
+
# Step 1: Compute checksums
|
|
472
|
+
checksums = compute_checksums(file_path)
|
|
473
|
+
|
|
474
|
+
# Step 2: Build R2 object key
|
|
475
|
+
object_key = f"{remote_path.strip('/')}/{remote_filename}"
|
|
476
|
+
|
|
477
|
+
# Step 3: Upload to R2
|
|
478
|
+
uploader.upload_file(
|
|
479
|
+
file_path,
|
|
480
|
+
object_key,
|
|
481
|
+
content_type=content_type,
|
|
482
|
+
progress_callback=progress_callback,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
# Step 4: Register with API
|
|
486
|
+
create_request = FileCreateRequest(
|
|
487
|
+
category=category,
|
|
488
|
+
entity=entity,
|
|
489
|
+
remote_path=remote_path,
|
|
490
|
+
remote_filename=remote_filename,
|
|
491
|
+
remote_version=remote_version,
|
|
492
|
+
name=name,
|
|
493
|
+
tags=tags,
|
|
494
|
+
extra=extra,
|
|
495
|
+
size=checksums.size,
|
|
496
|
+
md5=checksums.md5,
|
|
497
|
+
sha1=checksums.sha1,
|
|
498
|
+
sha256=checksums.sha256,
|
|
499
|
+
sha512=checksums.sha512,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
return self.create_file(create_request)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Custom exceptions for the r2index library."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class R2IndexError(Exception):
|
|
5
|
+
"""Base exception for r2index library errors."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str, status_code: int | None = None) -> None:
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.message = message
|
|
10
|
+
self.status_code = status_code
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthenticationError(R2IndexError):
|
|
14
|
+
"""Raised for 401/403 authentication/authorization errors."""
|
|
15
|
+
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NotFoundError(R2IndexError):
|
|
20
|
+
"""Raised for 404 not found errors."""
|
|
21
|
+
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ValidationError(R2IndexError):
|
|
26
|
+
"""Raised for 400 validation errors."""
|
|
27
|
+
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConflictError(R2IndexError):
|
|
32
|
+
"""Raised for 409 conflict errors."""
|
|
33
|
+
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class UploadError(R2IndexError):
|
|
38
|
+
"""Raised for R2 upload failures."""
|
|
39
|
+
|
|
40
|
+
pass
|