remote-upload 0.1.0__tar.gz
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.
- remote_upload-0.1.0/.gitignore +28 -0
- remote_upload-0.1.0/CHANGELOG.md +22 -0
- remote_upload-0.1.0/LICENSE +21 -0
- remote_upload-0.1.0/PKG-INFO +366 -0
- remote_upload-0.1.0/README.md +325 -0
- remote_upload-0.1.0/pyproject.toml +109 -0
- remote_upload-0.1.0/src/remote_upload/__init__.py +93 -0
- remote_upload-0.1.0/src/remote_upload/content.py +36 -0
- remote_upload-0.1.0/src/remote_upload/exceptions.py +40 -0
- remote_upload-0.1.0/src/remote_upload/facade.py +37 -0
- remote_upload-0.1.0/src/remote_upload/metered.py +91 -0
- remote_upload-0.1.0/src/remote_upload/progress.py +18 -0
- remote_upload-0.1.0/src/remote_upload/py.typed +0 -0
- remote_upload-0.1.0/src/remote_upload/request.py +148 -0
- remote_upload-0.1.0/src/remote_upload/result.py +57 -0
- remote_upload-0.1.0/src/remote_upload/target.py +30 -0
- remote_upload-0.1.0/src/remote_upload/targets/__init__.py +3 -0
- remote_upload-0.1.0/src/remote_upload/targets/azure.py +103 -0
- remote_upload-0.1.0/src/remote_upload/targets/gcs.py +107 -0
- remote_upload-0.1.0/src/remote_upload/targets/http.py +104 -0
- remote_upload-0.1.0/src/remote_upload/targets/httpx_target.py +112 -0
- remote_upload-0.1.0/src/remote_upload/targets/s3.py +120 -0
- remote_upload-0.1.0/src/remote_upload/targets/sftp.py +236 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Build artifacts
|
|
2
|
+
/dist/
|
|
3
|
+
/build/
|
|
4
|
+
*.egg-info/
|
|
5
|
+
src/*.egg-info/
|
|
6
|
+
|
|
7
|
+
# Bytecode / caches
|
|
8
|
+
__pycache__/
|
|
9
|
+
*.py[cod]
|
|
10
|
+
.pytest_cache/
|
|
11
|
+
.ruff_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
|
|
14
|
+
# Virtual environments
|
|
15
|
+
.venv/
|
|
16
|
+
venv/
|
|
17
|
+
|
|
18
|
+
# Logs
|
|
19
|
+
*.log
|
|
20
|
+
|
|
21
|
+
# OS / editor
|
|
22
|
+
.DS_Store
|
|
23
|
+
.idea/
|
|
24
|
+
.vscode/
|
|
25
|
+
|
|
26
|
+
# uv: the lock IS committed (reproducible CI via `uv sync --frozen`); it is
|
|
27
|
+
# excluded from the published sdist by the [tool.hatch.build.targets.sdist]
|
|
28
|
+
# allowlist, so it never ships inside the package.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-06-12 [unreleased]
|
|
9
|
+
|
|
10
|
+
Initial Python port of the Java remote-upload library.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Core facade `RemoteUpload` with fluent `RemoteUploadRequest` builder.
|
|
15
|
+
- `UploadTarget` port plus `UploadContent`, `UploadResult`, and `ProgressListener`.
|
|
16
|
+
- Metered streaming that reports byte count, progress, and an optional checksum.
|
|
17
|
+
- Error hierarchy split into `RetryableUploadError` and `TerminalUploadError`.
|
|
18
|
+
- `HttpTarget` backend (stdlib), always available.
|
|
19
|
+
- Optional backends: `S3Target` (`[s3]`), `AzureBlobTarget` (`[azure]`),
|
|
20
|
+
`GcsTarget` (`[gcs]`), `SftpTarget` (`[sftp]`), and `HttpxTarget` (`[httpx]`).
|
|
21
|
+
- `py.typed` marker (typed package).
|
|
22
|
+
- Requires Python >= 3.14.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Carlos Guillermo Reyes Ramiro
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: remote-upload
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Stream local content INTO remote storage (S3/MinIO, Azure Blob, GCS, SFTP, HTTP) through one tiny, framework-agnostic API. The write-side twin of remote-download.
|
|
5
|
+
Project-URL: Homepage, https://github.com/calcifux/remote-upload-python
|
|
6
|
+
Project-URL: Repository, https://github.com/calcifux/remote-upload-python
|
|
7
|
+
Project-URL: Changelog, https://github.com/calcifux/remote-upload-python/blob/main/CHANGELOG.md
|
|
8
|
+
Project-URL: Issues, https://github.com/calcifux/remote-upload-python/issues
|
|
9
|
+
Author: Carlos Guillermo Reyes Ramiro (@Calcifux)
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: azure-blob,gcs,http,minio,s3,sftp,storage,streaming,upload
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Classifier: Topic :: System :: Archiving
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.14
|
|
24
|
+
Provides-Extra: all
|
|
25
|
+
Requires-Dist: azure-storage-blob>=12.19; extra == 'all'
|
|
26
|
+
Requires-Dist: boto3>=1.34; extra == 'all'
|
|
27
|
+
Requires-Dist: google-cloud-storage>=2.14; extra == 'all'
|
|
28
|
+
Requires-Dist: httpx>=0.27; extra == 'all'
|
|
29
|
+
Requires-Dist: paramiko>=3.4; extra == 'all'
|
|
30
|
+
Provides-Extra: azure
|
|
31
|
+
Requires-Dist: azure-storage-blob>=12.19; extra == 'azure'
|
|
32
|
+
Provides-Extra: gcs
|
|
33
|
+
Requires-Dist: google-cloud-storage>=2.14; extra == 'gcs'
|
|
34
|
+
Provides-Extra: httpx
|
|
35
|
+
Requires-Dist: httpx>=0.27; extra == 'httpx'
|
|
36
|
+
Provides-Extra: s3
|
|
37
|
+
Requires-Dist: boto3>=1.34; extra == 's3'
|
|
38
|
+
Provides-Extra: sftp
|
|
39
|
+
Requires-Dist: paramiko>=3.4; extra == 'sftp'
|
|
40
|
+
Description-Content-Type: text/markdown
|
|
41
|
+
|
|
42
|
+
# remote-upload
|
|
43
|
+
|
|
44
|
+
[](https://github.com/calcifux/remote-upload-python/actions/workflows/ci.yml)
|
|
45
|
+
[](https://pypi.org/project/remote-upload/)
|
|
46
|
+
[](https://opensource.org/licenses/MIT)
|
|
47
|
+
[](https://www.python.org/)
|
|
48
|
+
[](https://peps.python.org/pep-0561/)
|
|
49
|
+
|
|
50
|
+
> **Stream local content INTO remote storage** (S3 / MinIO, Azure Blob, GCS, SFTP, authenticated HTTP) through one tiny, framework-agnostic API — the **write-side twin** of `remote-download`.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from remote_upload import RemoteUpload
|
|
54
|
+
|
|
55
|
+
result = (
|
|
56
|
+
RemoteUpload.to(target)
|
|
57
|
+
.body(stream, length)
|
|
58
|
+
.content_type("image/jpeg")
|
|
59
|
+
.upload()
|
|
60
|
+
)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`remote-download` pipes bytes *out* of a remote origin to your client. `remote-upload` is the other half: it pushes bytes *into* a remote destination. Same shape, mirrored — works in any web framework (Django, FastAPI, Flask), task queues, AWS Lambda, or plain CLI scripts: anywhere you can read bytes from a stream.
|
|
64
|
+
|
|
65
|
+
| | remote-download | remote-upload |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| Port | `DownloadOrigin.open() -> RemoteContent` | `UploadTarget.upload(UploadContent) -> UploadResult` |
|
|
68
|
+
| Facade | `RemoteDownload.from_(src).write_to(out)` | `RemoteUpload.to(target).body(stream, length).upload()` |
|
|
69
|
+
| Direction | remote -> your backend -> client | client -> your backend -> remote |
|
|
70
|
+
|
|
71
|
+
## Install
|
|
72
|
+
|
|
73
|
+
The core (`HttpTarget`) is **pure standard library** — no third-party dependencies. Cloud and SSH backends each pull in one SDK, gated behind an extra so you install only what you use:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install remote-upload
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
| Extra | Install | Backend | Brings in |
|
|
80
|
+
|---|---|---|---|
|
|
81
|
+
| *(none)* | `pip install remote-upload` | `HttpTarget` | stdlib only — always available |
|
|
82
|
+
| `s3` | `pip install "remote-upload[s3]"` | `S3Target` (S3 / MinIO / Ceph / LocalStack) | `boto3` |
|
|
83
|
+
| `azure` | `pip install "remote-upload[azure]"` | `AzureBlobTarget` | `azure-storage-blob` |
|
|
84
|
+
| `gcs` | `pip install "remote-upload[gcs]"` | `GcsTarget` | `google-cloud-storage` |
|
|
85
|
+
| `sftp` | `pip install "remote-upload[sftp]"` | `SftpTarget` | `paramiko` |
|
|
86
|
+
| `httpx` | `pip install "remote-upload[httpx]"` | `HttpxTarget` (retries / auth / proxy) | `httpx` |
|
|
87
|
+
| `all` | `pip install "remote-upload[all]"` | everything above | all of the above |
|
|
88
|
+
|
|
89
|
+
Targets are importable straight from the package root. The extra-gated ones are loaded lazily, so importing one without its SDK installed raises a clear `ImportError` telling you exactly which extra to install:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from remote_upload import RemoteUpload, S3Target, AzureBlobTarget # lazily resolved
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Requires **Python 3.14+**.
|
|
96
|
+
|
|
97
|
+
## Quick start
|
|
98
|
+
|
|
99
|
+
Every upload follows the same fluent shape: pick a target (or a URL), attach a body, optionally decorate it, then call `.upload()`.
|
|
100
|
+
|
|
101
|
+
### Plain HTTP PUT to a URL
|
|
102
|
+
|
|
103
|
+
A bare string is treated as an absolute HTTP/HTTPS URL and wrapped in a default `HttpTarget` (PUT, no auth):
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from remote_upload import RemoteUpload
|
|
107
|
+
|
|
108
|
+
result = (
|
|
109
|
+
RemoteUpload.to("https://api.example.com/files/report.pdf")
|
|
110
|
+
.body(b"%PDF-1.7 ...")
|
|
111
|
+
.content_type("application/pdf")
|
|
112
|
+
.upload()
|
|
113
|
+
)
|
|
114
|
+
print(result.key, result.bytes_transferred, "bytes")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### The three ways to supply a body
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
# 1. Raw bytes — content length is exact and inferred for you.
|
|
121
|
+
RemoteUpload.to(target).body(b"hello world").upload()
|
|
122
|
+
|
|
123
|
+
# 2. An open binary stream — pass length when you know it (cloud targets like
|
|
124
|
+
# S3 need it); omit it for chunked / unknown-length uploads.
|
|
125
|
+
with open("photo.jpg", "rb") as fh:
|
|
126
|
+
RemoteUpload.to(target).body(fh, length=204_800).upload()
|
|
127
|
+
|
|
128
|
+
# 3. A file on disk — body_file() infers length, and (unless already set) the
|
|
129
|
+
# filename and content type from the path.
|
|
130
|
+
RemoteUpload.to(target).body_file("/tmp/photo.jpg").upload()
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
> `upload()` consumes and **closes** the body stream. Build a fresh request per upload — instances are not reusable.
|
|
134
|
+
|
|
135
|
+
### Everything together
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
def on_progress(sent: int, total: int | None) -> None:
|
|
139
|
+
pct = (sent * 100 // total) if total else -1
|
|
140
|
+
print(f"uploaded {sent} / {total} bytes ({pct}%)")
|
|
141
|
+
|
|
142
|
+
result = (
|
|
143
|
+
RemoteUpload.to(target)
|
|
144
|
+
.body(stream, length=204_800)
|
|
145
|
+
.content_type("image/jpeg")
|
|
146
|
+
.metadata({"captured_by": "user-1", "album": "summer"})
|
|
147
|
+
.checksum("sha256") # also accepts Java-style "SHA-256"
|
|
148
|
+
.on_progress(on_progress)
|
|
149
|
+
.upload()
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
print(result.key)
|
|
153
|
+
print(result.etag)
|
|
154
|
+
print(result.checksum_hex)
|
|
155
|
+
print(f"{result.bytes_per_second / 1_048_576:.1f} MiB/s")
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Backends
|
|
159
|
+
|
|
160
|
+
Every target is constructed with **keyword arguments** and then handed to `RemoteUpload.to(...)`. The keyword names below match each target's constructor exactly.
|
|
161
|
+
|
|
162
|
+
### HttpTarget — authenticated HTTP PUT (stdlib, always available)
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from remote_upload import RemoteUpload, HttpTarget
|
|
166
|
+
|
|
167
|
+
target = HttpTarget(
|
|
168
|
+
"https://api.example.com/files/report.pdf",
|
|
169
|
+
method="PUT",
|
|
170
|
+
headers={"X-Api-Key": "secret"},
|
|
171
|
+
bearer="<token>", # adds "Authorization: Bearer <token>"
|
|
172
|
+
connect_timeout=10.0,
|
|
173
|
+
request_timeout=60.0,
|
|
174
|
+
)
|
|
175
|
+
RemoteUpload.to(target).body_file("/tmp/report.pdf").upload()
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### S3Target — S3 / MinIO / Ceph / LocalStack (`[s3]`)
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
from remote_upload import RemoteUpload, S3Target
|
|
182
|
+
|
|
183
|
+
target = S3Target(
|
|
184
|
+
bucket="my-bucket",
|
|
185
|
+
key="tenant-1/uploads/abc/photo.jpg",
|
|
186
|
+
endpoint="http://localhost:9000", # MinIO; omit for real AWS
|
|
187
|
+
access_key="minioadmin",
|
|
188
|
+
secret_key="minioadmin",
|
|
189
|
+
region="us-east-1",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
result = (
|
|
193
|
+
RemoteUpload.to(target)
|
|
194
|
+
.body(stream, length=204_800)
|
|
195
|
+
.content_type("image/jpeg")
|
|
196
|
+
.metadata({"captured_by": "user-1"})
|
|
197
|
+
.checksum("sha256")
|
|
198
|
+
.upload()
|
|
199
|
+
)
|
|
200
|
+
print(result.key, "etag=", result.etag, result.bytes_transferred, "bytes")
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Setting `endpoint` enables path-style addressing automatically (needed by most S3-compatible services); override with `path_style=...` if needed. Omit `access_key` / `secret_key` to fall back to the default boto3 credential chain (env vars, `~/.aws/credentials`, IAM roles). For high throughput inject a shared boto3 client with `client=...` — an injected client is reused and never closed by the target.
|
|
204
|
+
|
|
205
|
+
### AzureBlobTarget — Azure Blob Storage (`[azure]`)
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
from remote_upload import RemoteUpload, AzureBlobTarget
|
|
209
|
+
|
|
210
|
+
target = AzureBlobTarget(
|
|
211
|
+
container="uploads",
|
|
212
|
+
blob="tenant-1/photo.jpg",
|
|
213
|
+
connection_string="DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...",
|
|
214
|
+
)
|
|
215
|
+
RemoteUpload.to(target).body_file("/tmp/photo.jpg").content_type("image/jpeg").upload()
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Authenticate one of three ways: a full `connection_string`, an `endpoint` URL plus an optional `sas_token`, or a pre-built `BlobClient` passed as `client=...`. Uploads overwrite an existing blob.
|
|
219
|
+
|
|
220
|
+
### GcsTarget — Google Cloud Storage (`[gcs]`)
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
from remote_upload import RemoteUpload, GcsTarget
|
|
224
|
+
|
|
225
|
+
target = GcsTarget(
|
|
226
|
+
bucket="my-bucket",
|
|
227
|
+
object_name="tenant-1/photo.jpg",
|
|
228
|
+
project_id="my-gcp-project",
|
|
229
|
+
credentials_path="/etc/secrets/service-account.json",
|
|
230
|
+
)
|
|
231
|
+
RemoteUpload.to(target).body_file("/tmp/photo.jpg").content_type("image/jpeg").upload()
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Credentials resolve in order: an explicit `credentials` object, then a service-account JSON file via `credentials_path` (an optional `file:` prefix is stripped), then Application Default Credentials. Pass a pre-built `storage.Client` via `client=...` for reuse / tests — an injected client is never closed by the target.
|
|
235
|
+
|
|
236
|
+
### SftpTarget — SFTP over SSH (`[sftp]`)
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
from remote_upload import RemoteUpload, SftpTarget
|
|
240
|
+
|
|
241
|
+
target = SftpTarget(
|
|
242
|
+
host="sftp.example.com",
|
|
243
|
+
user="deploy",
|
|
244
|
+
path="/uploads/photo.jpg",
|
|
245
|
+
port=22,
|
|
246
|
+
password="s3cr3t", # or use private_key_path=...
|
|
247
|
+
)
|
|
248
|
+
RemoteUpload.to(target).body_file("/tmp/photo.jpg").upload()
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Authenticate with a `password` or a `private_key_path` (Ed25519 / ECDSA / RSA / DSA are tried in order). Authentication failures surface as `TerminalUploadError`; connection and I/O failures as `RetryableUploadError`. Tune `connect_timeout` / `auth_timeout` as needed.
|
|
252
|
+
|
|
253
|
+
### HttpxTarget — HTTP PUT with retries / auth / proxy (`[httpx]`)
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
from remote_upload import RemoteUpload, HttpxTarget
|
|
257
|
+
|
|
258
|
+
target = HttpxTarget(
|
|
259
|
+
"https://api.example.com/files/report.pdf",
|
|
260
|
+
method="PUT",
|
|
261
|
+
bearer="<token>", # or basic_auth=("user", "pass")
|
|
262
|
+
retries=3,
|
|
263
|
+
connect_timeout=30.0,
|
|
264
|
+
response_timeout=300.0,
|
|
265
|
+
proxy="http://corp-proxy:3128",
|
|
266
|
+
)
|
|
267
|
+
RemoteUpload.to(target).body_file("/tmp/report.pdf").upload()
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
The richer twin of the stdlib `HttpTarget`: transport-level retries, `Bearer` / `Basic` auth, granular timeouts and an optional forward `proxy`. Pass an existing `httpx.Client` via `client=...` to reuse a connection pool.
|
|
271
|
+
|
|
272
|
+
## Concepts
|
|
273
|
+
|
|
274
|
+
The library is built around a single port (`UploadTarget`) and three plain data types. Implement the port and you can push bytes to anything.
|
|
275
|
+
|
|
276
|
+
### `UploadTarget` — the port
|
|
277
|
+
|
|
278
|
+
A `Protocol` with one method:
|
|
279
|
+
|
|
280
|
+
```python
|
|
281
|
+
def upload(self, content: UploadContent) -> UploadResult: ...
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Each backend supplies its own implementation; consumers push bytes through the same API regardless of where they land. Custom destinations only need this single method. Implementations **read** `content.body` but do **not** own its lifecycle — the request opens and closes the stream for them.
|
|
285
|
+
|
|
286
|
+
### `UploadContent` — the payload
|
|
287
|
+
|
|
288
|
+
A frozen dataclass the facade builds and hands to the target:
|
|
289
|
+
|
|
290
|
+
| Field | Meaning |
|
|
291
|
+
|---|---|
|
|
292
|
+
| `body` | live binary stream to read from (already metered / checksummed) |
|
|
293
|
+
| `content_length` | size in bytes, or `None` when unknown |
|
|
294
|
+
| `content_type` | MIME type to store, or `None` |
|
|
295
|
+
| `filename` | suggested filename / key tail, or `None` |
|
|
296
|
+
| `metadata` | user metadata mapping (never `None`; empty when unset) |
|
|
297
|
+
|
|
298
|
+
### `UploadResult` — the outcome
|
|
299
|
+
|
|
300
|
+
A frozen dataclass combining the target's provider identifiers with the transfer stats the request measures:
|
|
301
|
+
|
|
302
|
+
| Field / property | Meaning |
|
|
303
|
+
|---|---|
|
|
304
|
+
| `key` | object key / remote path the bytes were written to |
|
|
305
|
+
| `location` | fully-qualified URL / URI, when the provider exposes one |
|
|
306
|
+
| `etag` | provider ETag (S3 / Azure), when available |
|
|
307
|
+
| `version_id` | provider version id, when versioning is enabled |
|
|
308
|
+
| `bytes_transferred` | total bytes streamed to the destination |
|
|
309
|
+
| `duration` | wall-clock `timedelta` of the upload |
|
|
310
|
+
| `content_type` | content type stored with the object |
|
|
311
|
+
| `checksum_algorithm` | algorithm requested via `.checksum(...)`, or `None` |
|
|
312
|
+
| `checksum_hex` | lower-case hex digest, or `None` if none requested |
|
|
313
|
+
| `bytes_per_second` | computed throughput (`0` when duration is zero/`None`) |
|
|
314
|
+
|
|
315
|
+
### `ProgressListener` — progress callback
|
|
316
|
+
|
|
317
|
+
A callable `(bytes_transferred: int, total_bytes: int | None) -> None`, fired as the destination reads the body. `total_bytes` is `None` for chunked / unknown-length uploads. Register it with `.on_progress(...)`:
|
|
318
|
+
|
|
319
|
+
```python
|
|
320
|
+
RemoteUpload.to(target).body(data).on_progress(
|
|
321
|
+
lambda sent, total: print(f"{sent}/{total}")
|
|
322
|
+
).upload()
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## Error handling — retryable vs terminal
|
|
326
|
+
|
|
327
|
+
Targets translate provider failures into one of two exceptions so callers can branch on retry semantics **without parsing messages**. Both subclass `RemoteUploadError`:
|
|
328
|
+
|
|
329
|
+
- **`RetryableUploadError`** — transient: a network blip, a 5xx response, a timeout. Callers with a retry budget (an offline outbox, a sync coordinator) should re-enqueue with backoff.
|
|
330
|
+
- **`TerminalUploadError`** — permanent: invalid credentials, a 4xx, quota exceeded, validation. Retrying the same request will fail again; change something (re-auth, fix the payload, escalate) instead.
|
|
331
|
+
|
|
332
|
+
```python
|
|
333
|
+
from remote_upload import (
|
|
334
|
+
RemoteUpload,
|
|
335
|
+
RetryableUploadError,
|
|
336
|
+
TerminalUploadError,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
RemoteUpload.to(target).body_file("/tmp/photo.jpg").upload()
|
|
341
|
+
except RetryableUploadError:
|
|
342
|
+
enqueue_for_retry(...) # backoff and try again later
|
|
343
|
+
except TerminalUploadError:
|
|
344
|
+
mark_failed_and_alert(...) # do not retry; surface to the user
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
This retryable/terminal split is the deliberate improvement over a single exception type: it lets a sync coordinator decide between "keep retrying" and "mark failed, surface to user".
|
|
348
|
+
|
|
349
|
+
## Java -> Python mapping
|
|
350
|
+
|
|
351
|
+
This package is a faithful port of the Java library [`remote-upload-java`](https://github.com/calcifux/remote-upload-java). If you know one, you know the other:
|
|
352
|
+
|
|
353
|
+
| Java | Python |
|
|
354
|
+
|---|---|
|
|
355
|
+
| `RemoteUpload.to(target)` | `RemoteUpload.to(target)` (same) |
|
|
356
|
+
| `.body(in, len).contentType(...).metadata(k, v)` | `.body(stream, len).content_type(...).metadata({...})` |
|
|
357
|
+
| `.onProgress(...)` / `.checksum("SHA-256")` | `.on_progress(...)` / `.checksum("sha256")` (or `"SHA-256"`) |
|
|
358
|
+
| `S3Target.builder().bucket(...).key(...).credentials(ak, sk).build()` | `S3Target(bucket=..., key=..., access_key=..., secret_key=...)` |
|
|
359
|
+
| `RetryableUploadException` / `TerminalUploadException` | `RetryableUploadError` / `TerminalUploadError` |
|
|
360
|
+
| `UploadResult.getKey()` / `.etag()` | `UploadResult.key` / `.etag` (plain attributes) |
|
|
361
|
+
|
|
362
|
+
In short: `*Exception` becomes `*Error`, fluent builders become keyword arguments, and getters become attributes.
|
|
363
|
+
|
|
364
|
+
## License
|
|
365
|
+
|
|
366
|
+
MIT (c) Carlos Guillermo Reyes Ramiro. See [LICENSE](https://github.com/calcifux/remote-upload-python/blob/main/LICENSE).
|