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