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,204 @@
1
+ """Pydantic models for r2index API requests and responses."""
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class RemoteTuple(BaseModel):
10
+ """Remote file identifier tuple."""
11
+
12
+ remote_path: str
13
+ remote_filename: str
14
+ remote_version: str
15
+
16
+
17
+ class FileCreateRequest(BaseModel):
18
+ """Request payload for creating/upserting a file record."""
19
+
20
+ category: str
21
+ entity: str
22
+ remote_path: str
23
+ remote_filename: str
24
+ remote_version: str
25
+ name: str | None = None
26
+ tags: list[str] | None = None
27
+ extra: dict[str, Any] | None = None
28
+ size: int
29
+ md5: str
30
+ sha1: str
31
+ sha256: str
32
+ sha512: str
33
+
34
+
35
+ class FileUpdateRequest(BaseModel):
36
+ """Request payload for updating a file record."""
37
+
38
+ category: str | None = None
39
+ entity: str | None = None
40
+ remote_path: str | None = None
41
+ remote_filename: str | None = None
42
+ remote_version: str | None = None
43
+ name: str | None = None
44
+ tags: list[str] | None = None
45
+ extra: dict[str, Any] | None = None
46
+ size: int | None = None
47
+ md5: str | None = None
48
+ sha1: str | None = None
49
+ sha256: str | None = None
50
+ sha512: str | None = None
51
+
52
+
53
+ class FileRecord(BaseModel):
54
+ """File record as returned by the API."""
55
+
56
+ id: str
57
+ category: str
58
+ entity: str
59
+ remote_path: str
60
+ remote_filename: str
61
+ remote_version: str
62
+ name: str | None = None
63
+ tags: list[str] = Field(default_factory=list)
64
+ extra: dict[str, Any] | None = None
65
+ size: int
66
+ md5: str
67
+ sha1: str
68
+ sha256: str
69
+ sha512: str
70
+ created_at: datetime
71
+ updated_at: datetime
72
+
73
+
74
+ class FileListResponse(BaseModel):
75
+ """Response for listing files."""
76
+
77
+ files: list[FileRecord]
78
+ total: int
79
+ page: int
80
+ page_size: int = Field(alias="pageSize")
81
+
82
+ model_config = {"populate_by_name": True}
83
+
84
+
85
+ class IndexEntry(BaseModel):
86
+ """Single entry in the index response."""
87
+
88
+ id: str
89
+ category: str
90
+ entity: str
91
+ remote_path: str
92
+ remote_filename: str
93
+ remote_version: str
94
+ name: str | None = None
95
+ tags: list[str] = Field(default_factory=list)
96
+ size: int
97
+ md5: str
98
+ sha1: str
99
+ sha256: str
100
+ sha512: str
101
+
102
+
103
+ class DownloadRecordRequest(BaseModel):
104
+ """Request payload for recording a download."""
105
+
106
+ file_id: str = Field(alias="fileId")
107
+ ip_address: str = Field(alias="ipAddress")
108
+ user_agent: str | None = Field(default=None, alias="userAgent")
109
+
110
+ model_config = {"populate_by_name": True}
111
+
112
+
113
+ class DownloadRecord(BaseModel):
114
+ """Download record as returned by the API."""
115
+
116
+ id: str
117
+ file_id: str = Field(alias="fileId")
118
+ ip_address: str = Field(alias="ipAddress")
119
+ user_agent: str | None = Field(default=None, alias="userAgent")
120
+ downloaded_at: datetime = Field(alias="downloadedAt")
121
+
122
+ model_config = {"populate_by_name": True}
123
+
124
+
125
+ class TimeseriesDataPoint(BaseModel):
126
+ """Single data point in timeseries analytics."""
127
+
128
+ timestamp: datetime
129
+ count: int
130
+
131
+
132
+ class TimeseriesResponse(BaseModel):
133
+ """Response for timeseries analytics."""
134
+
135
+ data: list[TimeseriesDataPoint]
136
+ start: datetime
137
+ end: datetime
138
+ granularity: str
139
+
140
+
141
+ class SummaryResponse(BaseModel):
142
+ """Response for summary analytics."""
143
+
144
+ total_downloads: int = Field(alias="totalDownloads")
145
+ unique_ips: int = Field(alias="uniqueIps")
146
+ unique_files: int = Field(alias="uniqueFiles")
147
+ start: datetime
148
+ end: datetime
149
+
150
+ model_config = {"populate_by_name": True}
151
+
152
+
153
+ class DownloadByIpEntry(BaseModel):
154
+ """Single download entry for by-IP analytics."""
155
+
156
+ file_id: str = Field(alias="fileId")
157
+ downloaded_at: datetime = Field(alias="downloadedAt")
158
+ user_agent: str | None = Field(default=None, alias="userAgent")
159
+
160
+ model_config = {"populate_by_name": True}
161
+
162
+
163
+ class DownloadsByIpResponse(BaseModel):
164
+ """Response for downloads by IP analytics."""
165
+
166
+ ip_address: str = Field(alias="ipAddress")
167
+ downloads: list[DownloadByIpEntry]
168
+ total: int
169
+
170
+ model_config = {"populate_by_name": True}
171
+
172
+
173
+ class UserAgentEntry(BaseModel):
174
+ """Single user agent entry in analytics."""
175
+
176
+ user_agent: str = Field(alias="userAgent")
177
+ count: int
178
+
179
+ model_config = {"populate_by_name": True}
180
+
181
+
182
+ class UserAgentsResponse(BaseModel):
183
+ """Response for user agents analytics."""
184
+
185
+ user_agents: list[UserAgentEntry] = Field(alias="userAgents")
186
+ start: datetime
187
+ end: datetime
188
+
189
+ model_config = {"populate_by_name": True}
190
+
191
+
192
+ class CleanupResponse(BaseModel):
193
+ """Response for cleanup operations."""
194
+
195
+ deleted_count: int = Field(alias="deletedCount")
196
+
197
+ model_config = {"populate_by_name": True}
198
+
199
+
200
+ class HealthResponse(BaseModel):
201
+ """Response for health check."""
202
+
203
+ status: str
204
+ timestamp: datetime
@@ -0,0 +1,147 @@
1
+ """Synchronous R2 uploader using boto3."""
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ import boto3
8
+ from boto3.s3.transfer import TransferConfig
9
+
10
+ from .exceptions import UploadError
11
+
12
+ # 100MB threshold and part size for multipart uploads
13
+ MULTIPART_THRESHOLD = 100 * 1024 * 1024
14
+ MULTIPART_PART_SIZE = 100 * 1024 * 1024
15
+
16
+
17
+ @dataclass
18
+ class R2Config:
19
+ """Configuration for R2 storage."""
20
+
21
+ access_key_id: str
22
+ secret_access_key: str
23
+ endpoint_url: str
24
+ bucket: str
25
+ region: str = "auto"
26
+
27
+
28
+ class R2Uploader:
29
+ """Synchronous R2 uploader using boto3."""
30
+
31
+ def __init__(self, config: R2Config) -> None:
32
+ """
33
+ Initialize the R2 uploader.
34
+
35
+ Args:
36
+ config: R2 configuration with credentials and endpoint.
37
+ """
38
+ self.config = config
39
+ self._client = boto3.client(
40
+ "s3",
41
+ aws_access_key_id=config.access_key_id,
42
+ aws_secret_access_key=config.secret_access_key,
43
+ endpoint_url=config.endpoint_url,
44
+ region_name=config.region,
45
+ )
46
+
47
+ def upload_file(
48
+ self,
49
+ file_path: str | Path,
50
+ object_key: str,
51
+ content_type: str | None = None,
52
+ progress_callback: Callable[[int], None] | None = None,
53
+ ) -> str:
54
+ """
55
+ Upload a file to R2.
56
+
57
+ Uses multipart upload for files larger than 100MB.
58
+
59
+ Args:
60
+ file_path: Path to the file to upload.
61
+ object_key: The key (path) to store the object under in R2.
62
+ content_type: Optional content type for the object.
63
+ progress_callback: Optional callback called with bytes uploaded so far.
64
+
65
+ Returns:
66
+ The object key of the uploaded file.
67
+
68
+ Raises:
69
+ UploadError: If the upload fails.
70
+ """
71
+ file_path = Path(file_path)
72
+
73
+ if not file_path.exists():
74
+ raise UploadError(f"File not found: {file_path}")
75
+
76
+ transfer_config = TransferConfig(
77
+ multipart_threshold=MULTIPART_THRESHOLD,
78
+ multipart_chunksize=MULTIPART_PART_SIZE,
79
+ use_threads=True,
80
+ )
81
+
82
+ extra_args = {}
83
+ if content_type:
84
+ extra_args["ContentType"] = content_type
85
+
86
+ callback = None
87
+ if progress_callback:
88
+ callback = _ProgressCallback(progress_callback)
89
+
90
+ try:
91
+ self._client.upload_file(
92
+ str(file_path),
93
+ self.config.bucket,
94
+ object_key,
95
+ Config=transfer_config,
96
+ ExtraArgs=extra_args if extra_args else None,
97
+ Callback=callback,
98
+ )
99
+ except Exception as e:
100
+ raise UploadError(f"Failed to upload file to R2: {e}") from e
101
+
102
+ return object_key
103
+
104
+ def delete_object(self, object_key: str) -> None:
105
+ """
106
+ Delete an object from R2.
107
+
108
+ Args:
109
+ object_key: The key of the object to delete.
110
+
111
+ Raises:
112
+ UploadError: If the deletion fails.
113
+ """
114
+ try:
115
+ self._client.delete_object(Bucket=self.config.bucket, Key=object_key)
116
+ except Exception as e:
117
+ raise UploadError(f"Failed to delete object from R2: {e}") from e
118
+
119
+ def object_exists(self, object_key: str) -> bool:
120
+ """
121
+ Check if an object exists in R2.
122
+
123
+ Args:
124
+ object_key: The key of the object to check.
125
+
126
+ Returns:
127
+ True if the object exists, False otherwise.
128
+ """
129
+ try:
130
+ self._client.head_object(Bucket=self.config.bucket, Key=object_key)
131
+ return True
132
+ except self._client.exceptions.ClientError as e:
133
+ if e.response["Error"]["Code"] == "404":
134
+ return False
135
+ raise UploadError(f"Failed to check object existence: {e}") from e
136
+
137
+
138
+ class _ProgressCallback:
139
+ """Wrapper to track cumulative progress for boto3 callback."""
140
+
141
+ def __init__(self, callback: Callable[[int], None]) -> None:
142
+ self._callback = callback
143
+ self._bytes_transferred = 0
144
+
145
+ def __call__(self, bytes_amount: int) -> None:
146
+ self._bytes_transferred += bytes_amount
147
+ self._callback(self._bytes_transferred)
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: elaunira-r2index
3
+ Version: 0.1.0
4
+ Summary: Python library for uploading files to R2 and registering them with the r2index API
5
+ Project-URL: Homepage, https://github.com/elaunira/elaunira-r2-index
6
+ Project-URL: Repository, https://github.com/elaunira/elaunira-r2-index
7
+ Author: Elaunira
8
+ License-Expression: MIT
9
+ Keywords: cloudflare,index,r2,storage,upload
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: aioboto3>=12.0.0
19
+ Requires-Dist: boto3>=1.34.0
20
+ Requires-Dist: httpx>=0.25.0
21
+ Requires-Dist: pydantic>=2.0.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: boto3-stubs[s3]>=1.34.0; extra == 'dev'
24
+ Requires-Dist: build>=1.0.0; extra == 'dev'
25
+ Requires-Dist: mypy>=1.8.0; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
27
+ Requires-Dist: pytest-httpx>=0.30.0; extra == 'dev'
28
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
29
+ Requires-Dist: ruff>=0.3.0; extra == 'dev'
30
+ Requires-Dist: twine>=5.0.0; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # elaunira-r2index
34
+
35
+ Python library for uploading files to Cloudflare R2 and registering them with the r2index API.
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ pip install elaunira-r2index
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ### Sync Client
46
+
47
+ ```python
48
+ from elaunira.r2index import R2IndexClient, R2Config
49
+
50
+ client = R2IndexClient(
51
+ api_url="https://r2index.example.com",
52
+ api_token="your-bearer-token",
53
+ r2_config=R2Config(
54
+ access_key_id="your-r2-access-key-id",
55
+ secret_access_key="your-r2-secret-access-key",
56
+ endpoint_url="https://your-account-id.r2.cloudflarestorage.com",
57
+ bucket="your-bucket-name",
58
+ ),
59
+ )
60
+
61
+ # Upload and register a file
62
+ record = client.upload_and_register(
63
+ file_path="./myfile.zip",
64
+ category="software",
65
+ entity="myapp",
66
+ remote_path="/releases",
67
+ remote_filename="myapp-1.0.0.zip",
68
+ remote_version="1.0.0",
69
+ tags=["release", "stable"],
70
+ )
71
+ ```
72
+
73
+ ### Async Client
74
+
75
+ ```python
76
+ from elaunira.r2index import AsyncR2IndexClient, R2Config
77
+
78
+ async with AsyncR2IndexClient(
79
+ api_url="https://r2index.example.com",
80
+ api_token="your-bearer-token",
81
+ r2_config=R2Config(
82
+ access_key_id="your-r2-access-key-id",
83
+ secret_access_key="your-r2-secret-access-key",
84
+ endpoint_url="https://your-account-id.r2.cloudflarestorage.com",
85
+ bucket="your-bucket-name",
86
+ ),
87
+ ) as client:
88
+ record = await client.upload_and_register(
89
+ file_path="./myfile.zip",
90
+ category="software",
91
+ entity="myapp",
92
+ remote_path="/releases",
93
+ remote_filename="myapp-1.0.0.zip",
94
+ remote_version="1.0.0",
95
+ tags=["release", "stable"],
96
+ )
97
+ ```
98
+
99
+ ## License
100
+
101
+ MIT
@@ -0,0 +1,12 @@
1
+ elaunira/__init__.py,sha256=qaXVGBU6uIJyveNTEbWux5EcfVSM186PvDwjyxiXLw4,34
2
+ elaunira/r2index/__init__.py,sha256=dPjPlDfBIUqzS9STNNtyMF1LyPakXeY5I1ypHEM_jTo,2878
3
+ elaunira/r2index/async_client.py,sha256=T8fWZKnJZOTgsDEvdvv67xL-d_QJ5pP1A3LznEZZ6gY,14936
4
+ elaunira/r2index/async_uploader.py,sha256=ctf65f2DjT9gckEh7XXpHRp1Ym14zv6vx1zIgN_SJpg,4868
5
+ elaunira/r2index/checksums.py,sha256=tqRTJ7j3pWLJlQ8FQE20JRYk9lXy5YZzymdpYOhsTFo,3281
6
+ elaunira/r2index/client.py,sha256=LPtE4uq_OKfmyFJKMKM_hdmU8euVAWkCe17T7HAzXL8,14608
7
+ elaunira/r2index/exceptions.py,sha256=JTSztfTH-jar2jSKEUmQO8VKfRNWfHTB0WgvwLJCVU8,795
8
+ elaunira/r2index/models.py,sha256=ie6uOpamrdmOc9bljym61FurRzpcsoQ1qB4rQQGu7_I,4756
9
+ elaunira/r2index/uploader.py,sha256=kKkC6f_YwMIJ-Tict7mn3S0Sdim6pI4rRXF0xvNwjh0,4250
10
+ elaunira_r2index-0.1.0.dist-info/METADATA,sha256=zQ8Up7wJ-7g26hwL-Ay3QXxgtTaIs8ljUGVgloupv3M,2923
11
+ elaunira_r2index-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
+ elaunira_r2index-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any