Skip to main content

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.

Project description

remote-upload

CI PyPI License: MIT Python 3.14+ Typed

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.

from remote_upload import RemoteUpload

result = (
    RemoteUpload.to(target)
    .body(stream, length)
    .content_type("image/jpeg")
    .upload()
)

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.

remote-download remote-upload
Port DownloadOrigin.open() -> RemoteContent UploadTarget.upload(UploadContent) -> UploadResult
Facade RemoteDownload.from_(src).write_to(out) RemoteUpload.to(target).body(stream, length).upload()
Direction remote -> your backend -> client client -> your backend -> remote

Install

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:

pip install remote-upload
Extra Install Backend Brings in
(none) pip install remote-upload HttpTarget stdlib only — always available
s3 pip install "remote-upload[s3]" S3Target (S3 / MinIO / Ceph / LocalStack) boto3
azure pip install "remote-upload[azure]" AzureBlobTarget azure-storage-blob
gcs pip install "remote-upload[gcs]" GcsTarget google-cloud-storage
sftp pip install "remote-upload[sftp]" SftpTarget paramiko
httpx pip install "remote-upload[httpx]" HttpxTarget (retries / auth / proxy) httpx
all pip install "remote-upload[all]" everything above all of the above

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:

from remote_upload import RemoteUpload, S3Target, AzureBlobTarget  # lazily resolved

Requires Python 3.14+.

Quick start

Every upload follows the same fluent shape: pick a target (or a URL), attach a body, optionally decorate it, then call .upload().

Plain HTTP PUT to a URL

A bare string is treated as an absolute HTTP/HTTPS URL and wrapped in a default HttpTarget (PUT, no auth):

from remote_upload import RemoteUpload

result = (
    RemoteUpload.to("https://api.example.com/files/report.pdf")
    .body(b"%PDF-1.7 ...")
    .content_type("application/pdf")
    .upload()
)
print(result.key, result.bytes_transferred, "bytes")

The three ways to supply a body

# 1. Raw bytes — content length is exact and inferred for you.
RemoteUpload.to(target).body(b"hello world").upload()

# 2. An open binary stream — pass length when you know it (cloud targets like
#    S3 need it); omit it for chunked / unknown-length uploads.
with open("photo.jpg", "rb") as fh:
    RemoteUpload.to(target).body(fh, length=204_800).upload()

# 3. A file on disk — body_file() infers length, and (unless already set) the
#    filename and content type from the path.
RemoteUpload.to(target).body_file("/tmp/photo.jpg").upload()

upload() consumes and closes the body stream. Build a fresh request per upload — instances are not reusable.

Everything together

def on_progress(sent: int, total: int | None) -> None:
    pct = (sent * 100 // total) if total else -1
    print(f"uploaded {sent} / {total} bytes ({pct}%)")

result = (
    RemoteUpload.to(target)
    .body(stream, length=204_800)
    .content_type("image/jpeg")
    .metadata({"captured_by": "user-1", "album": "summer"})
    .checksum("sha256")            # also accepts Java-style "SHA-256"
    .on_progress(on_progress)
    .upload()
)

print(result.key)
print(result.etag)
print(result.checksum_hex)
print(f"{result.bytes_per_second / 1_048_576:.1f} MiB/s")

Backends

Every target is constructed with keyword arguments and then handed to RemoteUpload.to(...). The keyword names below match each target's constructor exactly.

HttpTarget — authenticated HTTP PUT (stdlib, always available)

from remote_upload import RemoteUpload, HttpTarget

target = HttpTarget(
    "https://api.example.com/files/report.pdf",
    method="PUT",
    headers={"X-Api-Key": "secret"},
    bearer="<token>",            # adds "Authorization: Bearer <token>"
    connect_timeout=10.0,
    request_timeout=60.0,
)
RemoteUpload.to(target).body_file("/tmp/report.pdf").upload()

S3Target — S3 / MinIO / Ceph / LocalStack ([s3])

from remote_upload import RemoteUpload, S3Target

target = S3Target(
    bucket="my-bucket",
    key="tenant-1/uploads/abc/photo.jpg",
    endpoint="http://localhost:9000",   # MinIO; omit for real AWS
    access_key="minioadmin",
    secret_key="minioadmin",
    region="us-east-1",
)

result = (
    RemoteUpload.to(target)
    .body(stream, length=204_800)
    .content_type("image/jpeg")
    .metadata({"captured_by": "user-1"})
    .checksum("sha256")
    .upload()
)
print(result.key, "etag=", result.etag, result.bytes_transferred, "bytes")

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.

AzureBlobTarget — Azure Blob Storage ([azure])

from remote_upload import RemoteUpload, AzureBlobTarget

target = AzureBlobTarget(
    container="uploads",
    blob="tenant-1/photo.jpg",
    connection_string="DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...",
)
RemoteUpload.to(target).body_file("/tmp/photo.jpg").content_type("image/jpeg").upload()

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.

GcsTarget — Google Cloud Storage ([gcs])

from remote_upload import RemoteUpload, GcsTarget

target = GcsTarget(
    bucket="my-bucket",
    object_name="tenant-1/photo.jpg",
    project_id="my-gcp-project",
    credentials_path="/etc/secrets/service-account.json",
)
RemoteUpload.to(target).body_file("/tmp/photo.jpg").content_type("image/jpeg").upload()

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.

SftpTarget — SFTP over SSH ([sftp])

from remote_upload import RemoteUpload, SftpTarget

target = SftpTarget(
    host="sftp.example.com",
    user="deploy",
    path="/uploads/photo.jpg",
    port=22,
    password="s3cr3t",                       # or use private_key_path=...
)
RemoteUpload.to(target).body_file("/tmp/photo.jpg").upload()

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.

HttpxTarget — HTTP PUT with retries / auth / proxy ([httpx])

from remote_upload import RemoteUpload, HttpxTarget

target = HttpxTarget(
    "https://api.example.com/files/report.pdf",
    method="PUT",
    bearer="<token>",                        # or basic_auth=("user", "pass")
    retries=3,
    connect_timeout=30.0,
    response_timeout=300.0,
    proxy="http://corp-proxy:3128",
)
RemoteUpload.to(target).body_file("/tmp/report.pdf").upload()

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.

Framework integrations

Optional one-line adapters take the upload object your web framework parsed and stream it straight into a target — carrying its content type and filename. Each lives behind its own extra:

pip install "remote-upload[fastapi]"   # or [flask] / [django]
# FastAPI / Starlette  — use a sync `def` so the transfer runs in the threadpool
from fastapi import UploadFile
from remote_upload import AzureBlobTarget
from remote_upload.integrations.fastapi import upload

@app.post("/uploads")
def create(file: UploadFile):
    target = AzureBlobTarget(container="media", blob=file.filename, connection_string=CONN)
    result = upload(target, file)
    return {"key": result.key, "bytes": result.bytes_transferred, "etag": result.etag}
# Flask
from flask import request
from remote_upload.integrations.flask import upload

@app.post("/uploads")
def create():
    file = request.files["file"]
    target = AzureBlobTarget(container="media", blob=file.filename, connection_string=CONN)
    result = upload(target, file)
    return {"key": result.key, "bytes": result.bytes_transferred}
# Django
from remote_upload.integrations.django import upload

def create(request):
    file = request.FILES["file"]
    target = AzureBlobTarget(container="media", blob=file.name, connection_string=CONN)
    result = upload(target, file)
    return JsonResponse({"key": result.key, "bytes": result.bytes_transferred})

Not on this list (plain WSGI/ASGI, a CLI, a task queue)? You need no adapter — use the core RemoteUpload.to(target).body(stream, length).upload() directly. Under the hood every adapter is a thin wrapper over the framework-neutral remote_upload.integrations._base.upload(target, stream=..., length=..., content_type=..., filename=...).

Concepts

The library is built around a single port (UploadTarget) and three plain data types. Implement the port and you can push bytes to anything.

UploadTarget — the port

A Protocol with one method:

def upload(self, content: UploadContent) -> UploadResult: ...

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.

UploadContent — the payload

A frozen dataclass the facade builds and hands to the target:

Field Meaning
body live binary stream to read from (already metered / checksummed)
content_length size in bytes, or None when unknown
content_type MIME type to store, or None
filename suggested filename / key tail, or None
metadata user metadata mapping (never None; empty when unset)

UploadResult — the outcome

A frozen dataclass combining the target's provider identifiers with the transfer stats the request measures:

Field / property Meaning
key object key / remote path the bytes were written to
location fully-qualified URL / URI, when the provider exposes one
etag provider ETag (S3 / Azure), when available
version_id provider version id, when versioning is enabled
bytes_transferred total bytes streamed to the destination
duration wall-clock timedelta of the upload
content_type content type stored with the object
checksum_algorithm algorithm requested via .checksum(...), or None
checksum_hex lower-case hex digest, or None if none requested
bytes_per_second computed throughput (0 when duration is zero/None)

ProgressListener — progress callback

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(...):

RemoteUpload.to(target).body(data).on_progress(
    lambda sent, total: print(f"{sent}/{total}")
).upload()

Error handling — retryable vs terminal

Targets translate provider failures into one of two exceptions so callers can branch on retry semantics without parsing messages. Both subclass RemoteUploadError:

  • 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.
  • 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.
from remote_upload import (
    RemoteUpload,
    RetryableUploadError,
    TerminalUploadError,
)

try:
    RemoteUpload.to(target).body_file("/tmp/photo.jpg").upload()
except RetryableUploadError:
    enqueue_for_retry(...)        # backoff and try again later
except TerminalUploadError:
    mark_failed_and_alert(...)    # do not retry; surface to the user

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".

Java -> Python mapping

This package is a faithful port of the Java library remote-upload-java. If you know one, you know the other:

Java Python
RemoteUpload.to(target) RemoteUpload.to(target) (same)
.body(in, len).contentType(...).metadata(k, v) .body(stream, len).content_type(...).metadata({...})
.onProgress(...) / .checksum("SHA-256") .on_progress(...) / .checksum("sha256") (or "SHA-256")
S3Target.builder().bucket(...).key(...).credentials(ak, sk).build() S3Target(bucket=..., key=..., access_key=..., secret_key=...)
RetryableUploadException / TerminalUploadException RetryableUploadError / TerminalUploadError
UploadResult.getKey() / .etag() UploadResult.key / .etag (plain attributes)

In short: *Exception becomes *Error, fluent builders become keyword arguments, and getters become attributes.

License

MIT (c) Carlos Guillermo Reyes Ramiro. See LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

remote_upload-0.2.0.tar.gz (23.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

remote_upload-0.2.0-py3-none-any.whl (32.6 kB view details)

Uploaded Python 3

File details

Details for the file remote_upload-0.2.0.tar.gz.

File metadata

  • Download URL: remote_upload-0.2.0.tar.gz
  • Upload date:
  • Size: 23.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.21 {"installer":{"name":"uv","version":"0.11.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for remote_upload-0.2.0.tar.gz
Algorithm Hash digest
SHA256 7dc8493239b932e5f01714cdeb3d2a8ee33d3cb5e94535a82e02593652c3156d
MD5 e6d632bab49dbd83e871280b55fe2277
BLAKE2b-256 454c211e12bab639c68d667a5db59c23dd1b1305972aa13919d581c821622bf5

See more details on using hashes here.

File details

Details for the file remote_upload-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: remote_upload-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 32.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.21 {"installer":{"name":"uv","version":"0.11.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for remote_upload-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 a1155705dc264e6e5a76eca8e338e3f09f686fd7c8a0d2e414351a691ad5a043
MD5 5e5ddab6707e63e0a4c41d3921cdf6a3
BLAKE2b-256 493bd274f2c05b320bb0b3efc9939bcc7e8f7922927b05cfec15691be8f6350c

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page