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.
@@ -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