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.
@@ -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
+ [![CI](https://github.com/calcifux/remote-upload-python/actions/workflows/ci.yml/badge.svg)](https://github.com/calcifux/remote-upload-python/actions/workflows/ci.yml)
45
+ [![PyPI](https://img.shields.io/pypi/v/remote-upload.svg)](https://pypi.org/project/remote-upload/)
46
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
47
+ [![Python 3.14+](https://img.shields.io/badge/Python-3.14%2B-blue.svg)](https://www.python.org/)
48
+ [![Typed](https://img.shields.io/badge/typing-strict-brightgreen.svg)](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).