elaunira-r2index 0.1.0__py3-none-any.whl → 0.3.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.
@@ -1,43 +1,11 @@
1
- """
2
- R2 Index Python Library.
1
+ """Python library for uploading and downloading files to/from Cloudflare R2 with the r2index API."""
3
2
 
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.
3
+ from importlib.metadata import version
7
4
 
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
- """
5
+ __version__ = version("elaunira-r2index")
38
6
 
39
7
  from .async_client import AsyncR2IndexClient
40
- from .async_uploader import AsyncR2Uploader
8
+ from .async_storage import AsyncR2Storage
41
9
  from .checksums import (
42
10
  ChecksumResult,
43
11
  compute_checksums,
@@ -48,6 +16,7 @@ from .client import R2IndexClient
48
16
  from .exceptions import (
49
17
  AuthenticationError,
50
18
  ConflictError,
19
+ DownloadError,
51
20
  NotFoundError,
52
21
  R2IndexError,
53
22
  UploadError,
@@ -72,44 +41,48 @@ from .models import (
72
41
  UserAgentEntry,
73
42
  UserAgentsResponse,
74
43
  )
75
- from .uploader import R2Config, R2Uploader
44
+ from .storage import R2Config, R2Storage, R2TransferConfig
76
45
 
77
46
  __all__ = [
47
+ # Version
48
+ "__version__",
78
49
  # Clients
79
- "R2IndexClient",
80
50
  "AsyncR2IndexClient",
81
- # Uploaders
82
- "R2Uploader",
83
- "AsyncR2Uploader",
51
+ "R2IndexClient",
52
+ # Storage
53
+ "AsyncR2Storage",
84
54
  "R2Config",
55
+ "R2Storage",
56
+ "R2TransferConfig",
85
57
  # Checksums
86
58
  "ChecksumResult",
87
59
  "compute_checksums",
88
60
  "compute_checksums_async",
89
61
  "compute_checksums_from_file_object",
90
62
  # Exceptions
91
- "R2IndexError",
92
63
  "AuthenticationError",
93
- "NotFoundError",
94
- "ValidationError",
95
64
  "ConflictError",
65
+ "DownloadError",
66
+ "NotFoundError",
67
+ "R2IndexError",
96
68
  "UploadError",
69
+ "ValidationError",
97
70
  # Models - File operations
98
- "FileRecord",
99
71
  "FileCreateRequest",
100
- "FileUpdateRequest",
101
72
  "FileListResponse",
73
+ "FileRecord",
74
+ "FileUpdateRequest",
102
75
  "IndexEntry",
103
76
  "RemoteTuple",
104
77
  # Models - Downloads
105
78
  "DownloadRecord",
106
79
  "DownloadRecordRequest",
107
80
  # Models - Analytics
108
- "TimeseriesDataPoint",
109
- "TimeseriesResponse",
110
- "SummaryResponse",
111
81
  "DownloadByIpEntry",
112
82
  "DownloadsByIpResponse",
83
+ "SummaryResponse",
84
+ "TimeseriesDataPoint",
85
+ "TimeseriesResponse",
113
86
  "UserAgentEntry",
114
87
  "UserAgentsResponse",
115
88
  # Models - Other
@@ -7,7 +7,7 @@ from typing import Any
7
7
 
8
8
  import httpx
9
9
 
10
- from .async_uploader import AsyncR2Uploader
10
+ from .async_storage import AsyncR2Storage
11
11
  from .checksums import compute_checksums_async
12
12
  from .exceptions import (
13
13
  AuthenticationError,
@@ -32,7 +32,48 @@ from .models import (
32
32
  TimeseriesResponse,
33
33
  UserAgentsResponse,
34
34
  )
35
- from .uploader import R2Config
35
+ from . import __version__
36
+ from .storage import R2Config, R2TransferConfig
37
+
38
+ CHECKIP_URL = "https://checkip.amazonaws.com"
39
+ DEFAULT_USER_AGENT = f"elaunira-r2index/{__version__}"
40
+
41
+
42
+ def _parse_object_id(object_id: str, bucket: str) -> RemoteTuple:
43
+ """
44
+ Parse an object_id into remote_path, remote_version, and remote_filename.
45
+
46
+ Format: /path/to/object/version/filename.ext
47
+ - remote_filename: last component (filename.ext)
48
+ - remote_version: second-to-last component (version)
49
+ - remote_path: everything before that (/path/to/object)
50
+
51
+ Args:
52
+ object_id: Full object path like /releases/myapp/v1/myapp.zip
53
+ bucket: The S3/R2 bucket name.
54
+
55
+ Returns:
56
+ RemoteTuple with parsed components including bucket.
57
+
58
+ Raises:
59
+ ValueError: If object_id doesn't have enough components.
60
+ """
61
+ parts = object_id.strip("/").split("/")
62
+ if len(parts) < 3:
63
+ raise ValueError(
64
+ f"object_id must have at least 3 components (path/version/filename), got: {object_id}"
65
+ )
66
+
67
+ remote_filename = parts[-1]
68
+ remote_version = parts[-2]
69
+ remote_path = "/" + "/".join(parts[:-2])
70
+
71
+ return RemoteTuple(
72
+ bucket=bucket,
73
+ remote_path=remote_path,
74
+ remote_filename=remote_filename,
75
+ remote_version=remote_version,
76
+ )
36
77
 
37
78
 
38
79
  class AsyncR2IndexClient:
@@ -40,29 +81,42 @@ class AsyncR2IndexClient:
40
81
 
41
82
  def __init__(
42
83
  self,
43
- api_url: str,
44
- api_token: str,
45
- r2_config: R2Config | None = None,
84
+ index_api_url: str,
85
+ index_api_token: str,
86
+ r2_access_key_id: str | None = None,
87
+ r2_secret_access_key: str | None = None,
88
+ r2_endpoint_url: str | None = None,
46
89
  timeout: float = 30.0,
47
90
  ) -> None:
48
91
  """
49
92
  Initialize the async R2Index client.
50
93
 
51
94
  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.
95
+ index_api_url: Base URL of the r2index API.
96
+ index_api_token: Bearer token for authentication.
97
+ r2_access_key_id: R2 access key ID for storage operations.
98
+ r2_secret_access_key: R2 secret access key for storage operations.
99
+ r2_endpoint_url: R2 endpoint URL for storage operations.
55
100
  timeout: Request timeout in seconds.
56
101
  """
57
- self.api_url = api_url.rstrip("/")
58
- self._token = api_token
102
+ self.api_url = index_api_url.rstrip("/")
103
+ self._token = index_api_token
59
104
  self._timeout = timeout
60
- self._r2_config = r2_config
61
- self._uploader: AsyncR2Uploader | None = None
105
+ self._storage: AsyncR2Storage | None = None
106
+
107
+ # Build R2 config if credentials provided
108
+ if r2_access_key_id and r2_secret_access_key and r2_endpoint_url:
109
+ self._r2_config: R2Config | None = R2Config(
110
+ access_key_id=r2_access_key_id,
111
+ secret_access_key=r2_secret_access_key,
112
+ endpoint_url=r2_endpoint_url,
113
+ )
114
+ else:
115
+ self._r2_config = None
62
116
 
63
117
  self._client = httpx.AsyncClient(
64
118
  base_url=self.api_url,
65
- headers={"Authorization": f"Bearer {api_token}"},
119
+ headers={"Authorization": f"Bearer {index_api_token}"},
66
120
  timeout=timeout,
67
121
  )
68
122
 
@@ -76,13 +130,13 @@ class AsyncR2IndexClient:
76
130
  """Close the HTTP client."""
77
131
  await self._client.aclose()
78
132
 
79
- def _get_uploader(self) -> AsyncR2Uploader:
133
+ def _get_storage(self) -> AsyncR2Storage:
80
134
  """Get or create the async R2 uploader."""
81
135
  if self._r2_config is None:
82
136
  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
137
+ if self._storage is None:
138
+ self._storage = AsyncR2Storage(self._r2_config)
139
+ return self._storage
86
140
 
87
141
  def _handle_response(self, response: httpx.Response) -> Any:
88
142
  """Handle API response and raise appropriate exceptions."""
@@ -109,8 +163,9 @@ class AsyncR2IndexClient:
109
163
 
110
164
  # File Operations
111
165
 
112
- async def list_files(
166
+ async def list(
113
167
  self,
168
+ bucket: str | None = None,
114
169
  category: str | None = None,
115
170
  entity: str | None = None,
116
171
  tags: list[str] | None = None,
@@ -121,6 +176,7 @@ class AsyncR2IndexClient:
121
176
  List files with optional filters.
122
177
 
123
178
  Args:
179
+ bucket: Filter by bucket.
124
180
  category: Filter by category.
125
181
  entity: Filter by entity.
126
182
  tags: Filter by tags.
@@ -131,6 +187,8 @@ class AsyncR2IndexClient:
131
187
  FileListResponse with files and pagination info.
132
188
  """
133
189
  params: dict[str, Any] = {}
190
+ if bucket:
191
+ params["bucket"] = bucket
134
192
  if category:
135
193
  params["category"] = category
136
194
  if entity:
@@ -146,7 +204,7 @@ class AsyncR2IndexClient:
146
204
  data = self._handle_response(response)
147
205
  return FileListResponse.model_validate(data)
148
206
 
149
- async def create_file(self, data: FileCreateRequest) -> FileRecord:
207
+ async def create(self, data: FileCreateRequest) -> FileRecord:
150
208
  """
151
209
  Create or upsert a file record.
152
210
 
@@ -160,7 +218,7 @@ class AsyncR2IndexClient:
160
218
  result = self._handle_response(response)
161
219
  return FileRecord.model_validate(result)
162
220
 
163
- async def get_file(self, file_id: str) -> FileRecord:
221
+ async def get(self, file_id: str) -> FileRecord:
164
222
  """
165
223
  Get a file by ID.
166
224
 
@@ -177,7 +235,7 @@ class AsyncR2IndexClient:
177
235
  data = self._handle_response(response)
178
236
  return FileRecord.model_validate(data)
179
237
 
180
- async def update_file(self, file_id: str, data: FileUpdateRequest) -> FileRecord:
238
+ async def update(self, file_id: str, data: FileUpdateRequest) -> FileRecord:
181
239
  """
182
240
  Update a file record.
183
241
 
@@ -195,7 +253,7 @@ class AsyncR2IndexClient:
195
253
  result = self._handle_response(response)
196
254
  return FileRecord.model_validate(result)
197
255
 
198
- async def delete_file(self, file_id: str) -> None:
256
+ async def delete(self, file_id: str) -> None:
199
257
  """
200
258
  Delete a file by ID.
201
259
 
@@ -208,17 +266,18 @@ class AsyncR2IndexClient:
208
266
  response = await self._client.delete(f"/files/{file_id}")
209
267
  self._handle_response(response)
210
268
 
211
- async def delete_file_by_tuple(self, remote_tuple: RemoteTuple) -> None:
269
+ async def delete_by_tuple(self, remote_tuple: RemoteTuple) -> None:
212
270
  """
213
271
  Delete a file by remote tuple.
214
272
 
215
273
  Args:
216
- remote_tuple: The remote path, filename, and version.
274
+ remote_tuple: The bucket, remote path, filename, and version.
217
275
 
218
276
  Raises:
219
277
  NotFoundError: If the file is not found.
220
278
  """
221
279
  params = {
280
+ "bucket": remote_tuple.bucket,
222
281
  "remotePath": remote_tuple.remote_path,
223
282
  "remoteFilename": remote_tuple.remote_filename,
224
283
  "remoteVersion": remote_tuple.remote_version,
@@ -226,8 +285,32 @@ class AsyncR2IndexClient:
226
285
  response = await self._client.delete("/files", params=params)
227
286
  self._handle_response(response)
228
287
 
229
- async def get_index(
288
+ async def get_by_tuple(self, remote_tuple: RemoteTuple) -> FileRecord:
289
+ """
290
+ Get a file by remote tuple.
291
+
292
+ Args:
293
+ remote_tuple: The bucket, remote path, filename, and version.
294
+
295
+ Returns:
296
+ The FileRecord.
297
+
298
+ Raises:
299
+ NotFoundError: If the file is not found.
300
+ """
301
+ params = {
302
+ "bucket": remote_tuple.bucket,
303
+ "remotePath": remote_tuple.remote_path,
304
+ "remoteFilename": remote_tuple.remote_filename,
305
+ "remoteVersion": remote_tuple.remote_version,
306
+ }
307
+ response = await self._client.get("/files/by-tuple", params=params)
308
+ data = self._handle_response(response)
309
+ return FileRecord.model_validate(data)
310
+
311
+ async def index(
230
312
  self,
313
+ bucket: str | None = None,
231
314
  category: str | None = None,
232
315
  entity: str | None = None,
233
316
  tags: list[str] | None = None,
@@ -236,6 +319,7 @@ class AsyncR2IndexClient:
236
319
  Get file index (lightweight listing).
237
320
 
238
321
  Args:
322
+ bucket: Filter by bucket.
239
323
  category: Filter by category.
240
324
  entity: Filter by entity.
241
325
  tags: Filter by tags.
@@ -244,6 +328,8 @@ class AsyncR2IndexClient:
244
328
  List of IndexEntry objects.
245
329
  """
246
330
  params: dict[str, Any] = {}
331
+ if bucket:
332
+ params["bucket"] = bucket
247
333
  if category:
248
334
  params["category"] = category
249
335
  if entity:
@@ -424,9 +510,10 @@ class AsyncR2IndexClient:
424
510
 
425
511
  # High-Level Pipeline
426
512
 
427
- async def upload_and_register(
513
+ async def upload(
428
514
  self,
429
- file_path: str | Path,
515
+ bucket: str,
516
+ local_path: str | Path,
430
517
  category: str,
431
518
  entity: str,
432
519
  remote_path: str,
@@ -447,7 +534,8 @@ class AsyncR2IndexClient:
447
534
  3. Register with r2index API
448
535
 
449
536
  Args:
450
- file_path: Path to the file to upload.
537
+ bucket: The S3/R2 bucket name.
538
+ local_path: Local path to the file to upload.
451
539
  category: File category.
452
540
  entity: File entity.
453
541
  remote_path: Remote path in R2 (e.g., "/data/files").
@@ -466,18 +554,19 @@ class AsyncR2IndexClient:
466
554
  R2IndexError: If R2 config is not provided.
467
555
  UploadError: If upload fails.
468
556
  """
469
- file_path = Path(file_path)
470
- uploader = self._get_uploader()
557
+ local_path = Path(local_path)
558
+ uploader = self._get_storage()
471
559
 
472
560
  # Step 1: Compute checksums
473
- checksums = await compute_checksums_async(file_path)
561
+ checksums = await compute_checksums_async(local_path)
474
562
 
475
563
  # Step 2: Build R2 object key
476
564
  object_key = f"{remote_path.strip('/')}/{remote_filename}"
477
565
 
478
566
  # Step 3: Upload to R2
479
567
  await uploader.upload_file(
480
- file_path,
568
+ local_path,
569
+ bucket,
481
570
  object_key,
482
571
  content_type=content_type,
483
572
  progress_callback=progress_callback,
@@ -485,6 +574,7 @@ class AsyncR2IndexClient:
485
574
 
486
575
  # Step 4: Register with API
487
576
  create_request = FileCreateRequest(
577
+ bucket=bucket,
488
578
  category=category,
489
579
  entity=entity,
490
580
  remote_path=remote_path,
@@ -500,4 +590,86 @@ class AsyncR2IndexClient:
500
590
  sha512=checksums.sha512,
501
591
  )
502
592
 
503
- return await self.create_file(create_request)
593
+ return await self.create(create_request)
594
+
595
+ async def _get_public_ip(self) -> str:
596
+ """Fetch public IP address from checkip.amazonaws.com."""
597
+ async with httpx.AsyncClient() as client:
598
+ response = await client.get(CHECKIP_URL, timeout=10.0)
599
+ return response.text.strip()
600
+
601
+ async def download(
602
+ self,
603
+ bucket: str,
604
+ object_id: str,
605
+ destination: str | Path,
606
+ ip_address: str | None = None,
607
+ user_agent: str | None = None,
608
+ progress_callback: Callable[[int], None] | None = None,
609
+ transfer_config: R2TransferConfig | None = None,
610
+ ) -> tuple[Path, FileRecord]:
611
+ """
612
+ Download a file from R2 and record the download in the index asynchronously.
613
+
614
+ This is a convenience method that performs:
615
+ 1. Parse object_id into remote_path, remote_version, remote_filename
616
+ 2. Fetch file record from the API using these components
617
+ 3. Download the file from R2
618
+ 4. Record the download in the index for analytics
619
+
620
+ Args:
621
+ bucket: The S3/R2 bucket name.
622
+ object_id: Full S3 object path in format: /path/to/object/version/filename
623
+ Example: /releases/myapp/v1/myapp.zip
624
+ - remote_path: /releases/myapp
625
+ - remote_version: v1
626
+ - remote_filename: myapp.zip
627
+ destination: Local path where the file will be saved.
628
+ ip_address: IP address of the downloader. If not provided, fetched
629
+ from checkip.amazonaws.com.
630
+ user_agent: User agent string. Defaults to "elaunira-r2index/<version>".
631
+ progress_callback: Optional callback for download progress.
632
+ transfer_config: Optional transfer configuration for multipart/threading.
633
+
634
+ Returns:
635
+ A tuple of (downloaded file path, file record).
636
+
637
+ Raises:
638
+ R2IndexError: If R2 config is not provided.
639
+ ValueError: If object_id format is invalid.
640
+ NotFoundError: If the file is not found in the index.
641
+ DownloadError: If download fails.
642
+ """
643
+ storage = self._get_storage()
644
+
645
+ # Resolve defaults
646
+ if ip_address is None:
647
+ ip_address = await self._get_public_ip()
648
+ if user_agent is None:
649
+ user_agent = DEFAULT_USER_AGENT
650
+
651
+ # Step 1: Parse object_id into components
652
+ remote_tuple = _parse_object_id(object_id, bucket)
653
+
654
+ # Step 2: Get file record by tuple
655
+ file_record = await self.get_by_tuple(remote_tuple)
656
+
657
+ # Step 3: Build R2 object key and download
658
+ object_key = object_id.strip("/")
659
+ downloaded_path = await storage.download_file(
660
+ bucket,
661
+ object_key,
662
+ destination,
663
+ progress_callback=progress_callback,
664
+ transfer_config=transfer_config,
665
+ )
666
+
667
+ # Step 4: Record the download
668
+ download_request = DownloadRecordRequest(
669
+ file_id=file_record.id,
670
+ ip_address=ip_address,
671
+ user_agent=user_agent,
672
+ )
673
+ await self.record_download(download_request)
674
+
675
+ return downloaded_path, file_record