Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
2623056
Add regression test suite
scotttrinh Feb 6, 2026
e314b2d
Extract _http module and move iter_coroutine
scotttrinh Feb 10, 2026
ffe453d
Build shared blob request core
scotttrinh Feb 10, 2026
842055f
Unify transport send path
scotttrinh Feb 10, 2026
fadb421
Refactor blob ops around async core
scotttrinh Feb 10, 2026
d040463
Refactor blob ops to transport clients
scotttrinh Feb 10, 2026
67be7df
Refactor multipart auto upload runtimes
scotttrinh Feb 10, 2026
e234a74
Unify multipart core and API clients
scotttrinh Feb 10, 2026
fa254c2
Document new pattern
scotttrinh Feb 10, 2026
e62c5d7
Unify blob multipart runtime upload path
scotttrinh Feb 10, 2026
26d7d04
Migrate blob reads/downloads to _http
scotttrinh Feb 10, 2026
74c9cb0
Fix async multipart final progress bytes
scotttrinh Feb 10, 2026
c6dd0fc
Collapse multipart API adapter layers
scotttrinh Feb 10, 2026
6179c4b
Harden blob sync iter_coroutine boundaries
scotttrinh Feb 10, 2026
2327ace
More consistent "sync" vs "blocking"
scotttrinh Feb 10, 2026
876e15a
Fix blob download cleanup flow
scotttrinh Feb 10, 2026
e2c5432
"Blocking" -> "Sync"
scotttrinh Feb 10, 2026
d9aa5ad
Unify upload/download ops
scotttrinh Feb 13, 2026
244cb38
Unify blob get core and wrappers
scotttrinh Feb 13, 2026
d03d974
Simplify blob delete telemetry count
scotttrinh Feb 13, 2026
3b16622
Add blob get and delete parity tests
scotttrinh Feb 13, 2026
19f7956
Deduplicate blob delete normalization
scotttrinh Feb 13, 2026
2d9be69
Use `anyio` for concurrency control
scotttrinh Feb 23, 2026
1b063bc
More strict body parsing
scotttrinh Feb 23, 2026
ef9db2a
Collapse the nesting across Blob -> Multipart
scotttrinh Feb 23, 2026
9222771
Add additional tests for public APIs
scotttrinh Feb 23, 2026
4ce1c28
Merge remote-tracking branch 'origin/main' into iter-coroutine-blob
scotttrinh Feb 23, 2026
75a2c7b
Refactor incoming changes to shared transport
scotttrinh Feb 23, 2026
8bd4f8c
Oops! Actually use the correct result type
scotttrinh Feb 23, 2026
b877b55
Maximize reuse of the HTTP transport instance
scotttrinh Feb 23, 2026
0b1434c
blob: Remove duplicated helpers from ops.py
scotttrinh Feb 23, 2026
7ed4a2d
blob: Use consistent default_timeout of 30s for get
scotttrinh Feb 23, 2026
0b0f44d
Move list/iter objects methods to clients
scotttrinh Feb 23, 2026
7766d08
blob: Add return type annotations to create_multipart_uploader
scotttrinh Feb 23, 2026
14bb8c3
Fix multipart example failing across event loops
scotttrinh Feb 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# AGENTS.md

## Local Workflow

- Install/sync dependencies: `uv sync`
- Run all tests: `uv run pytest`
- Run a focused test file: `uv run pytest tests/path/to/test_file.py`
- Run lint checks: `uv run ruff check .`
- Auto-fix lint issues where possible: `uv run ruff check --fix .`
- Format code: `uv run ruff format .`

## Before Opening a PR

- Run `uv run ruff check .`
- Run `uv run ruff format --check .` (or `uv run ruff format .`)
- Run relevant tests for changed areas, then run `uv run pytest` if changes are broad

## Commit Message Guidance

- Keep commit messages short and specific.
- Use a title line of 50 characters or fewer.
- Wrap commit message body lines at 72 characters.
- Explain what changed and why.
- Do not list file-by-file changes that are obvious from the diff.
- Do not include any `Co-authored-by:` line.

### Good examples

- `Add shared HTTP transport helpers`
- `Move iter_coroutine to a dedicated module`
- `Fix async request hook header handling`

## Iter-Coroutine + Base/Runtime Migration Pattern

Use this as the default shape when refactoring sync+async modules to reduce
duplication.

### Core principles

- Keep public API stable: same exported names, signatures, return types, and
behavior.
- Make the internal core async-first.
- Make sync entrypoints thin wrappers over async core via `iter_coroutine(...)`
only when the wrapped coroutine is non-suspending in sync mode.
- Route HTTP through `vercel._http` clients/transports; avoid direct
`httpx.Client`/`httpx.AsyncClient` construction in refactored feature modules.

### Recommended structure

- Create a private async base class for shared logic:
- Example shape: `_Base<Domain>Client` with async methods for shared ops.
- Keep parsing/validation/result-shaping helpers in this layer.
- Add private sync/async concrete classes:
- Sync uses `SyncTransport(...)` and sync callbacks.
- Async uses `AsyncTransport(...)` and awaitable callbacks.
- Keep public sync functions as wrappers that call
`iter_coroutine(base_client.async_method(...))`.
- Keep public async functions as direct `await` on the same async methods.

### Base + runtime split (for true runtime-specific behavior)

When sync and async must differ materially (threading vs asyncio scheduling),
use a runtime contract and shared orchestration:

- Define one runtime method name (for example, `upload(...)`).
- Implement two runtimes:
- blocking runtime: threadpool/locks/sync callback handling.
- async runtime: `asyncio.create_task`/`asyncio.wait`/awaitable callbacks.
- Keep common orchestration shared:
- validation
- chunk/part iteration helpers
- normalization/order of results
- final response shaping

### `iter_coroutine` guardrails

- Safe: sync wrappers around coroutines that complete without real suspension.
- Unsafe: coroutines that rely on event-loop scheduling (network awaits,
`asyncio.sleep`, task scheduling, etc.).
- For mixed callback paths, use explicit `inspect.isawaitable(...)` checks in
async code rather than forcing everything through `iter_coroutine`.

### Testing expectations for migrations

- Prefer integration-style tests with `respx` that verify real request flow and
sync/async parity.
- Do not rely only on monkeypatch tests that assert internal call shape.
- Validate before commit:
- `uv run ruff check .`
- `uv run ruff format --check .`
- targeted tests for changed modules
- `uv run pytest` when changes are broad
117 changes: 60 additions & 57 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,26 +153,28 @@ Notes:

Requires `BLOB_READ_WRITE_TOKEN` to be set as an env var or `token` to be set when constructing a client

`BlobClient` and `AsyncBlobClient` keep a long-lived HTTP transport for the life of
the client instance. Prefer `with BlobClient(...)` / `async with AsyncBlobClient(...)`
or call `close()` / `aclose()` explicitly when done.


#### Sync


```python
from vercel.blob import BlobClient

client = BlobClient()
# or BlobClient(token="...")

# Create a folder entry, upload a local file, list, then download
client.create_folder("examples/assets", overwrite=True)
uploaded = client.upload_file(
"./README.md",
"examples/assets/readme-copy.txt",
access="public",
content_type="text/plain",
)
listing = client.list_objects(prefix="examples/assets/")
client.download_file(uploaded.url, "/tmp/readme-copy.txt", overwrite=True)
with BlobClient() as client: # or BlobClient(token="...")
# Create a folder entry, upload a local file, list, then download
client.create_folder("examples/assets", overwrite=True)
uploaded = client.upload_file(
"./README.md",
"examples/assets/readme-copy.txt",
access="public",
content_type="text/plain",
)
listing = client.list_objects(prefix="examples/assets/")
client.download_file(uploaded.url, "/tmp/readme-copy.txt", overwrite=True)
```

Async usage:
Expand All @@ -182,21 +184,20 @@ import asyncio
from vercel.blob import AsyncBlobClient

async def main():
client = AsyncBlobClient() # uses BLOB_READ_WRITE_TOKEN from env

# Upload bytes
uploaded = await client.put(
"examples/assets/hello.txt",
b"hello from python",
access="public",
content_type="text/plain",
)

# Inspect metadata, list, download bytes, then delete
meta = await client.head(uploaded.url)
listing = await client.list_objects(prefix="examples/assets/")
content = await client.get(uploaded.url)
await client.delete([b.url for b in listing.blobs])
async with AsyncBlobClient() as client: # uses BLOB_READ_WRITE_TOKEN from env
# Upload bytes
uploaded = await client.put(
"examples/assets/hello.txt",
b"hello from python",
access="public",
content_type="text/plain",
)

# Inspect metadata, list, download bytes, then delete
meta = await client.head(uploaded.url)
listing = await client.list_objects(prefix="examples/assets/")
content = await client.get(uploaded.url)
await client.delete([b.url for b in listing.blobs])

asyncio.run(main())
```
Expand All @@ -206,18 +207,17 @@ Synchronous usage:
```python
from vercel.blob import BlobClient

client = BlobClient() # or BlobClient(token="...")

# Create a folder entry, upload a local file, list, then download
client.create_folder("examples/assets", overwrite=True)
uploaded = client.upload_file(
"./README.md",
"examples/assets/readme-copy.txt",
access="public",
content_type="text/plain",
)
listing = client.list_objects(prefix="examples/assets/")
client.download_file(uploaded.url, "/tmp/readme-copy.txt", overwrite=True)
with BlobClient() as client: # or BlobClient(token="...")
# Create a folder entry, upload a local file, list, then download
client.create_folder("examples/assets", overwrite=True)
uploaded = client.upload_file(
"./README.md",
"examples/assets/readme-copy.txt",
access="public",
content_type="text/plain",
)
listing = client.list_objects(prefix="examples/assets/")
client.download_file(uploaded.url, "/tmp/readme-copy.txt", overwrite=True)
```

#### Multipart Uploads
Expand Down Expand Up @@ -253,33 +253,36 @@ A middle-ground that provides a clean API while giving you control over parts an
from vercel.blob import BlobClient, create_multipart_uploader

# Create the uploader (initializes the upload)
client = BlobClient()
uploader = client.create_multipart_uploader("large-file.bin", content_type="application/octet-stream")
with BlobClient() as client:
uploader = client.create_multipart_uploader(
"large-file.bin",
content_type="application/octet-stream",
)

# Upload parts (you control when and how)
parts = []
for i, chunk in enumerate(chunks, start=1):
part = uploader.upload_part(i, chunk)
parts.append(part)
# Upload parts (you control when and how)
parts = []
for i, chunk in enumerate(chunks, start=1):
part = uploader.upload_part(i, chunk)
parts.append(part)

# Complete the upload
result = uploader.complete(parts)
# Complete the upload
result = uploader.complete(parts)
```

Async version with concurrent uploads:

```python
from vercel.blob import AsyncBlobClient, create_multipart_uploader_async

client = AsyncBlobClient()
uploader = await client.create_multipart_uploader("large-file.bin")
async with AsyncBlobClient() as client:
uploader = await client.create_multipart_uploader("large-file.bin")

# Upload parts concurrently
tasks = [uploader.upload_part(i, chunk) for i, chunk in enumerate(chunks, start=1)]
parts = await asyncio.gather(*tasks)
# Upload parts concurrently
tasks = [uploader.upload_part(i, chunk) for i, chunk in enumerate(chunks, start=1)]
parts = await asyncio.gather(*tasks)

# Complete
result = await uploader.complete(parts)
# Complete
result = await uploader.complete(parts)
```

The uploader pattern is ideal when you:
Expand Down Expand Up @@ -347,4 +350,4 @@ uv run ruff format --check && uv run ruff check . && uv run mypy src && uv run p

## License

MIT
MIT
7 changes: 5 additions & 2 deletions examples/blob_storage_multipart.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,10 @@ def comparison_example():
print("Please set it to run these examples.")
exit(1)

async def async_main():
await async_example()
await async_with_file_example()

comparison_example()
sync_example()
asyncio.run(async_example())
asyncio.run(async_with_file_example())
asyncio.run(async_main())
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ dev = [
"pytest>=7.0.0",
"pytest-asyncio",
"pytest-xdist",
"respx>=0.21.0",
"mypy",
"build",
"twine",
Expand All @@ -53,6 +54,9 @@ vercel = ["py.typed"]
testpaths = ["tests"]
addopts = "-q"
asyncio_mode = "auto"
markers = [
"live: requires live API credentials (VERCEL_TOKEN, BLOB_READ_WRITE_TOKEN, etc.)",
]

[tool.mypy]
ignore_missing_imports = true
Expand Down
38 changes: 38 additions & 0 deletions src/vercel/_http/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Shared HTTP infrastructure for Vercel API clients."""

from .clients import (
create_base_async_client,
create_base_client,
create_headers_async_client,
create_headers_client,
create_vercel_async_client,
create_vercel_client,
)
from .config import DEFAULT_API_BASE_URL, DEFAULT_TIMEOUT
from .transport import (
AsyncTransport,
BaseTransport,
BytesBody,
JSONBody,
RawBody,
RequestBody,
SyncTransport,
)

__all__ = [
"DEFAULT_API_BASE_URL",
"DEFAULT_TIMEOUT",
"BaseTransport",
"SyncTransport",
"AsyncTransport",
"JSONBody",
"BytesBody",
"RawBody",
"RequestBody",
"create_vercel_client",
"create_vercel_async_client",
"create_headers_client",
"create_headers_async_client",
"create_base_client",
"create_base_async_client",
]
Loading