Skip to content

Add implementation of streaming abstractions - #535

Merged
pjbull merged 35 commits into
masterfrom
cloud-io-2
Oct 4, 2026
Merged

pjbull merged 35 commits into
masterfrom
cloud-io-2

Conversation

@pjbull

@pjbull pjbull commented Oct 28, 2025 •

Copy link
Copy Markdown
Member

Adds streaming I/O: a new FileCacheMode.streaming that makes CloudPath.open() read and write cloud objects directly instead of going through the local file cache.

What it does

  • client = S3Client(file_cache_mode=FileCacheMode.streaming) (or CLOUDPATHLIB_FILE_CACHE_MODE=streaming). Then path.open(...) returns the standard io.BufferedReader, io.BufferedWriter, or io.TextIOWrapper, backed by the cloud object.
  • Reads are ranged requests. Only the bytes you read are downloaded. Read streams are seekable, so zip and parquet readers work and fetch only what they need.
  • Writes are uploaded as you write. Small writes are a single PUT; larger ones use multipart uploads (S3, GCS XML API) or block uploads (Azure). The object only appears when the stream is closed; a failed write aborts the upload and leaves no partial object.
  • Supported for S3, Azure Blob Storage, Google Cloud Storage, HTTP/HTTPS, and the cloudpathlib.local mock clients.
  • buffering has the same meaning as in the builtin open(). The default buffer (5 MiB) is also the size of each ranged request.
  • Each client has a streaming_max_concurrency setting (default 4) that bounds background read-ahead on sequential reads and background part uploads on writes.
  • force_overwrite_to_cloud works as in cached mode: closing a write stream raises OverwriteNewerCloudError if the object changed while the stream was open. x modes raise CloudPathFileExistsError if the object appeared in the meantime.
  • copy, rename, and replace between clients stream from one object to the other.
  • Environment variables: CLOUDPATHLIB_STREAMING_BUFFER_SIZE, CLOUDPATHLIB_STREAMING_MAX_CONCURRENCY, CLOUDPATHLIB_STREAMING_PART_SIZE, and CLOUDPATHLIB_<S3|AZURE|GS>_STREAMING_PART_SIZE.

Limitations

  • Append and update modes (a, r+, w+, ...) cannot stream over object storage. They fall back to the local cache (download, modify, re-upload on close, then remove the cache file) and emit a UserWarning.
  • fspath raises CloudPathNotImplementedError in streaming mode because there is no local file. Pass the open file object to libraries instead of a path.
  • HTTP reads need a server that honors Range requests. HTTP writes are a single request on close.
  • Errors raise cloudpathlib.exceptions types; CloudPathStreamingError (an OSError) is new.

Implementation

  • cloudpathlib/cloud_io.py: a RawIOBase adapter over client hooks (_range_download, _put_object, _initiate_multipart_upload, _upload_part, _complete_multipart_upload, _abort_multipart_upload), wrapped by the stdlib buffered and text classes. Each Client declares its raw stream class and multipart limits as class attributes, so custom client subclasses inherit streaming.
  • cloudpathlib/env.py: environment-variable handling shared by the library.
  • Content-type guessing for uploads is shared between the cached and streaming paths. GCS no longer sends Content-Encoding, which made .gz objects transcode on read.
  • Mocks gained the new SDK calls (ranged get_object, multipart, upload_from_file), a thread-safe Azure metadata cache, and a fix for the ADLS gen2 mock reporting a None size.
  • Docs: docs/docs/streaming_io.md and a new section in the caching notebook.

Testing

  • Mocked suite passes on all rigs. Live streaming tests pass on S3, custom S3, GCS, Azure Blob, and Azure Data Lake gen2.
  • Live tests on real buckets: USE_LIVE_CLOUD=1 CLOUDPATHLIB_TEST_RIGS=s3,gs,azure,azure_gen2 python -m pytest tests/test_cloud_io.py.

Closes #9

@pjbull
pjbull requested a review from Copilot October 28, 2025 22:28
@github-actions

Copy link
Copy Markdown
Contributor

@github-actions
github-actions Bot temporarily deployed to pull request October 28, 2025 22:30 Inactive
@codecov

codecov Bot commented Oct 28, 2025 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.25503% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.1%. Comparing base (753749d) to head (bbdddfb).

Files with missing lines Patch % Lines
cloudpathlib/cloud_io.py 97.6% 8 Missing ⚠️
cloudpathlib/azure/azblobclient.py 97.6% 1 Missing ⚠️
cloudpathlib/env.py 96.4% 1 Missing ⚠️
cloudpathlib/http/httpclient.py 98.3% 1 Missing ⚠️
cloudpathlib/local/localclient.py 97.2% 1 Missing ⚠️
cloudpathlib/s3/s3client.py 98.1% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##           master    #535     +/-   ##
========================================
+ Coverage    95.1%   96.1%   +0.9%     
========================================
  Files          28      30      +2     
  Lines        2286    2977    +691     
========================================
+ Hits         2176    2861    +685     
- Misses        110     116      +6     
Files with missing lines Coverage Δ
cloudpathlib/client.py 91.3% <100.0%> (+3.1%) ⬆️
cloudpathlib/cloudpath.py 96.5% <100.0%> (+0.6%) ⬆️
cloudpathlib/enums.py 100.0% <100.0%> (ø)
cloudpathlib/exceptions.py 100.0% <100.0%> (ø)
cloudpathlib/gs/gsclient.py 95.6% <100.0%> (+4.5%) ⬆️
cloudpathlib/local/implementations/azure.py 97.9% <100.0%> (+0.2%) ⬆️
cloudpathlib/local/implementations/gs.py 97.3% <100.0%> (+0.1%) ⬆️
cloudpathlib/local/implementations/s3.py 100.0% <100.0%> (ø)
cloudpathlib/azure/azblobclient.py 96.6% <97.6%> (+<0.1%) ⬆️
cloudpathlib/env.py 96.4% <96.4%> (ø)
... and 4 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR adds streaming I/O support to cloudpathlib, enabling direct file streaming from/to cloud storage (S3, Azure Blob Storage, GCS, and HTTP/HTTPS) without local caching. Key changes include:

  • New FileCacheMode.streaming enum value for direct streaming without caching
  • CloudBufferedIO and CloudTextIO classes implementing standard Python I/O interfaces
  • Provider-specific raw I/O adapters for efficient range requests and multipart uploads

Reviewed Changes

Copilot reviewed 32 out of 32 changed files in this pull request and generated 28 comments.

Show a summary per file
File Description
cloudpathlib/enums.py Added streaming enum value to FileCacheMode
cloudpathlib/cloud_io.py New module with CloudBufferedIO, CloudTextIO, and _CloudStorageRaw base classes
cloudpathlib/cloudpath.py Updated CloudImplementation to track raw I/O classes; modified open() and __fspath__() for streaming mode
cloudpathlib/client.py Added abstract methods for streaming I/O operations
cloudpathlib/s3/s3_io.py S3-specific streaming I/O implementation
cloudpathlib/azure/azure_io.py Azure-specific streaming I/O implementation
cloudpathlib/gs/gs_io.py GCS-specific streaming I/O implementation
cloudpathlib/http/http_io.py HTTP-specific streaming I/O implementation
tests/test_cloud_io.py Comprehensive test suite for streaming I/O functionality
tests/conftest.py Updated test fixtures to use CloudImplementation with raw I/O classes
tests/test_cloudpath_instantiation.py Updated test to access dependencies_loaded via cloud_implementation
tests/mock_clients/mock_s3.py Added mock methods for range requests and multipart uploads
tests/mock_clients/mock_gs.py Added mock methods for range downloads and uploads
tests/mock_clients/mock_azureblob.py Added mock methods for range downloads and block uploads
tests/http_fixtures.py Added Range request handling to HTTP test server
docs/docs/streaming_io.md Comprehensive documentation for streaming I/O feature
docs/docs/caching.ipynb Updated caching documentation to include streaming mode

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/conftest.py
rig = CloudProviderTestRig(
path_class=LocalS3Path,
client_class=LocalS3Client,
cloud_implementation=local_s3_implementation,

Copilot AI Oct 28, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect variable used: local_s3_implementation should be local_s3_cloud_implementation. This passes the wrong CloudImplementation object (the one from local.implementations.s3 module instead of the test-specific one created on line 559).

Suggested change
cloud_implementation=local_s3_implementation,
cloud_implementation=local_s3_cloud_implementation,

Copilot uses AI. Check for mistakes.
Comment thread cloudpathlib/cloudpath.py Outdated

def validate_completeness(self) -> None:
expected = ["client_class", "path_class"]
expected = ["client_class", "path_class", "raw_io_class"]

Copilot AI Oct 28, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation now requires raw_io_class to be present, but this is a breaking change. Existing custom implementations won't have this attribute set, causing them to fail validation. Consider making raw_io_class optional for backward compatibility, or only validate it when streaming mode is enabled.

Suggested change
expected = ["client_class", "path_class", "raw_io_class"]
expected = ["client_class", "path_class"]

Copilot uses AI. Check for mistakes.
Comment thread cloudpathlib/cloud_io.py Outdated
# Flush the buffer first (which will call write() on the raw stream)
try:
self._buffer.flush()
except Exception:

Copilot AI Oct 28, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'except' clause does nothing but pass and there is no explanatory comment.

Copilot uses AI. Check for mistakes.
Comment thread cloudpathlib/cloud_io.py Outdated
# Try to access the internal _closed attribute
if hasattr(self._buffer, "_closed"):
object.__setattr__(self._buffer, "_closed", True)
except Exception:

Copilot AI Oct 28, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'except' clause does nothing but pass and there is no explanatory comment.

Suggested change
except Exception:
except Exception:
# Ignore errors when setting _closed; not critical for buffer cleanup.

Copilot uses AI. Check for mistakes.
Comment thread tests/test_cloud_io.py Outdated
path.client.file_cache_mode = original_mode
try:
path.unlink()
except Exception:

Copilot AI Oct 28, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'except' clause does nothing but pass and there is no explanatory comment.

Copilot uses AI. Check for mistakes.
Comment thread tests/test_cloud_io.py
finally:
try:
path.unlink()
except Exception:

Copilot AI Oct 28, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'except' clause does nothing but pass and there is no explanatory comment.

Copilot uses AI. Check for mistakes.
Comment thread tests/test_cloud_io.py
finally:
try:
path.unlink()
except Exception:

Copilot AI Oct 28, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'except' clause does nothing but pass and there is no explanatory comment.

Copilot uses AI. Check for mistakes.
Comment thread tests/test_cloud_io.py
finally:
try:
path.unlink()
except Exception:

Copilot AI Oct 28, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'except' clause does nothing but pass and there is no explanatory comment.

Copilot uses AI. Check for mistakes.
Comment thread tests/test_cloud_io.py
finally:
try:
path.unlink()
except Exception:

Copilot AI Oct 28, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'except' clause does nothing but pass and there is no explanatory comment.

Copilot uses AI. Check for mistakes.
Comment thread tests/test_cloud_io.py
finally:
try:
path.unlink()
except Exception:

Copilot AI Oct 28, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'except' clause does nothing but pass and there is no explanatory comment.

Copilot uses AI. Check for mistakes.
@github-actions
github-actions Bot temporarily deployed to pull request July 11, 2026 16:01 Inactive
@github-actions
github-actions Bot temporarily deployed to pull request July 11, 2026 16:17 Inactive
@github-actions
github-actions Bot temporarily deployed to pull request July 11, 2026 16:59 Inactive
@github-actions
github-actions Bot temporarily deployed to pull request July 11, 2026 17:13 Inactive
@github-actions
github-actions Bot temporarily deployed to pull request July 11, 2026 17:24 Inactive
@github-actions
github-actions Bot temporarily deployed to pull request July 11, 2026 17:28 Inactive
@github-actions
github-actions Bot temporarily deployed to pull request July 11, 2026 17:33 Inactive
@github-actions
github-actions Bot temporarily deployed to pull request July 11, 2026 18:15 Inactive
@github-actions
github-actions Bot temporarily deployed to pull request July 20, 2026 16:00 Inactive
@pjbull
pjbull marked this pull request as ready for review July 20, 2026 16:02
pjbull and others added 12 commits October 3, 2026 10:23
Introduces a new FileCacheMode.streaming that opens cloud files as
Python-standard BufferedIOBase/TextIOWrapper objects backed by provider
range-requests and multipart/resumable uploads, without requiring a local
cache copy.

New public API:
- CloudBufferedIO: BufferedIOBase wrapper for binary streaming
- CloudTextIO: TextIOWrapper for text streaming
- FileCacheMode.streaming enum value

Provider implementations:
- S3: multipart upload with 5 MiB minimum non-final part size;
  _put_empty_object for zero-byte files
- GCS: blob.open("wb") resumable write stream; _put_empty_object
- Azure Blob: block-blob stage/commit pipeline; _put_empty_object
- HTTP(S): single-PUT via self.opener (honours SSL context)
- LocalClient: _LocalWriteStream + UUID-keyed multipart buffers for
  concurrent-write safety

Correctness fixes included:
- _CloudStorageRaw.close() propagates finalize errors via try/finally
- seekable() returns False for write-only streams
- Exclusive-create ("x") mode checked against cloud existence
- Append/update modes ("a", "+") fall back to file-cache path
- Client streaming methods default to NotImplementedError (not
  @AbstractMethod) so custom Client subclasses don't require
  re-implementation
- HTTP _put_data uses self.opener for SSL-aware PUT requests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- test_s3_multipart_upload, test_azure_block_upload, test_gs_resumable_upload
  were skipping on ALL rigs because hasattr(rig, "s3_path") etc. is always
  False; switch to rig.path_class.cloud_prefix comparisons so they run on
  the correct rigs
- gs/gsclient.py GCSNotFound fallback: type: ignore[misc, assignment]
- azure/azblobclient.py: e.error_code → e.error.code (guarded for None)
- gs/gs_io.py: annotate _writer as Optional[Any] to satisfy mypy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Review fixes (verified by adversarial review of this PR):
- write() advances the raw position so tell() is correct on streaming
  write streams (fixes corrupt output from zipfile and other
  position-dependent writers)
- seek() on write-only raw streams raises io.UnsupportedOperation as
  documented instead of silently accepting the seek
- GS range errors are matched structurally (status 416) instead of by a
  "416" substring that could swallow unrelated transient errors
- Azure block IDs are namespaced by a per-upload session ID so
  concurrent writers cannot clobber each other's staged blocks
- read() to EOF fetches the remainder in one ranged request (readall)
  instead of one request per 8 KiB
- copy()/rename()/replace() work in streaming mode by streaming between
  clients instead of round-tripping through fspath
- streaming writes honor force_overwrite_to_cloud, raising
  OverwriteNewerCloudError on close instead of clobbering a newer object
- Client.__del__ cleans up streaming-mode cache files from the
  append/update fallback; docs no longer claim streaming never touches
  disk
- streaming error paths raise cloudpathlib exception types; failed size
  lookups are memoized per stream; negative buffering matches
  builtins.open; behavior changes recorded in HISTORY.md

Coverage and fidelity:
- S3 extra-args filtering consults botocore's bundled service model
  instead of a hand-copied (and already stale) fallback table, with a
  drift-guard test
- mocks now raise realistic SDK errors for missing objects and
  past-EOF ranges; HTTP test server clamps range ends per RFC 7233;
  GS mock enforces the 5 MiB non-final part minimum
- default streaming buffer raised to 5 MiB; regression tests for all of
  the above plus parquet-over-streaming compatibility (pyarrow dev dep)

Streaming concurrency:
- new streaming_max_concurrency client parameter: bounded background
  part uploads while writing and read-ahead prefetch while reading,
  sequential by default
- GS streaming writes use the XML API multipart upload (via the SDK's
  transfer-manager machinery) with in-memory parts, replacing the
  resumable write stream and unifying all multipart providers on one
  write contract; mock GS implements the XML MPU wire protocol

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@pjbull
pjbull deployed to live-cloud-ci October 3, 2026 18:04 — with GitHub Actions Active
@pjbull
pjbull deployed to live-cloud-ci October 3, 2026 18:04 — with GitHub Actions Active
@github-actions
github-actions Bot temporarily deployed to pull request October 3, 2026 18:05 Inactive
@pjbull
pjbull requested a balanced review from Copilot October 3, 2026 20:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Streaming moves can retain their source, conflict checks remain racy, and large writes can exceed the documented memory bound.

Review effort: Balanced
Findings: 2 High severity · 3 Medium severity · 2 Low severity

Open (7)

Comment thread cloudpathlib/cloud_io.py
Comment on lines +221 to +223
if self._pre_finalize is not None:
self._pre_finalize()
self._finalize_upload()
Comment thread cloudpathlib/cloudpath.py Outdated
Comment on lines +1393 to +1399
with self.open("rb") as src_file:
with target_path.open("wb", force_overwrite_to_cloud=True) as dst_file:
# the streaming buffer size rather than shutil's 64 KiB default, so
# each buffered read is forwarded in one iteration
shutil.copyfileobj(src_file, dst_file, env.streaming_buffer_size())

return cast(Union[Path, Self], target_path)
Comment thread cloudpathlib/client.py Outdated
Comment on lines +80 to +82
if streaming_max_concurrency < 1:
raise ValueError("streaming_max_concurrency must be at least 1")
self.streaming_max_concurrency = streaming_max_concurrency
Comment thread cloudpathlib/cloud_io.py
Comment on lines +429 to +437
def _upload_chunk(self, data: _ReadableBuffer) -> None:
view = memoryview(data)
if view.nbytes == 0:
return
self._write_buffer.extend(view)
target_size = self._target_part_size()
while len(self._write_buffer) >= target_size:
self._upload_buffered_part(target_size)
target_size = self._target_part_size()
Comment thread cloudpathlib/s3/s3client.py Outdated
Comment on lines +427 to +430
body = response["Body"]
data = body.read()
body.close()
return data
Comment thread docs/docs/caching.ipynb Outdated
Comment on lines +770 to +772
"execution_count": null,
"metadata": {},
"outputs": [],
Comment thread docs/docs/streaming_io.md Outdated
Comment on lines +7 to +8
Nothing is written to disk, only the part of the object you read is downloaded, and
written data is uploaded while you write it.
pjbull and others added 2 commits October 3, 2026 14:43
…ation

- move() across clients now removes the source after the destination is
  written, in both the streaming branch and the cached branch (which never
  did on master).
- A single write larger than the part size is sliced into parts as it is
  copied, so the part buffer never exceeds one part.
- streaming_max_concurrency must be an int (not a float or bool).
- S3 ranged reads close the response body even when the read fails.
- Docs no longer claim streaming never touches disk (HTTP uploads spool).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The two streaming example cells had never been executed and the script
export was stale.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@pjbull
pjbull deployed to live-cloud-ci October 3, 2026 21:43 — with GitHub Actions Active
@pjbull
pjbull deployed to live-cloud-ci October 3, 2026 21:43 — with GitHub Actions Active
@pjbull
pjbull deployed to live-cloud-ci October 3, 2026 21:43 — with GitHub Actions Active
@pjbull
pjbull deployed to live-cloud-ci October 3, 2026 21:43 — with GitHub Actions Active
@pjbull
pjbull deployed to live-cloud-ci October 3, 2026 21:43 — with GitHub Actions Active
@github-actions
github-actions Bot temporarily deployed to pull request October 3, 2026 21:44 Inactive
The fallbacks run at import time, so the tests re-import gsclient in a
subprocess with the relevant modules made unimportable (reloading in-process
would re-register GSClient and desynchronise the rest of the suite).
Coverage's subprocess patch is enabled so those lines count.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@pjbull
pjbull deployed to live-cloud-ci October 3, 2026 22:07 — with GitHub Actions Active
@pjbull
pjbull deployed to live-cloud-ci October 3, 2026 22:07 — with GitHub Actions Active
@pjbull
pjbull deployed to live-cloud-ci October 3, 2026 22:07 — with GitHub Actions Active
@pjbull
pjbull deployed to live-cloud-ci October 3, 2026 22:07 — with GitHub Actions Active
@pjbull
pjbull deployed to live-cloud-ci October 3, 2026 22:07 — with GitHub Actions Active
@github-actions
github-actions Bot temporarily deployed to pull request October 3, 2026 22:08 Inactive
The inline-code part of the 'File cache mode' headers was dropped from the
rendered table of contents, leaving five identical entries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@pjbull
pjbull deployed to live-cloud-ci October 4, 2026 17:29 — with GitHub Actions Active
@pjbull
pjbull deployed to live-cloud-ci October 4, 2026 17:29 — with GitHub Actions Active
@pjbull
pjbull deployed to live-cloud-ci October 4, 2026 17:29 — with GitHub Actions Active
@pjbull
pjbull deployed to live-cloud-ci October 4, 2026 17:29 — with GitHub Actions Active
@pjbull
pjbull deployed to live-cloud-ci October 4, 2026 17:29 — with GitHub Actions Active
@pjbull
pjbull merged commit 1846ae2 into master Oct 4, 2026
35 checks passed
@pjbull
pjbull deleted the cloud-io-2 branch October 4, 2026 17:35

This branch was successfully deployed

2 active deployments
pull request — bbdddfb5 Deployed Oct 4, 2026 by github-actions[bot]
live-cloud-ci — bbdddfb5 Deployed Oct 4, 2026 by pjbull via Live Tests (azure_gen2) #25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement partial or streaming reads/writes (CloudFile abstraction)

2 participants