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,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,,
|