Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 0 additions & 22 deletions .github/workflows/conventional-commits.yml

This file was deleted.

2 changes: 1 addition & 1 deletion .github/workflows/wheels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ jobs:
name: Release
environment:
name: pypi-release
url: https://pypi.org/p/obstore
url: https://pypi.org/p/obstore-databaas
permissions:
# IMPORTANT: this permission is mandatory for trusted publishing
id-token: write
Expand Down
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
# Changelog

## Unreleased
## [0.12.1] - 2026-08-19

Published from the [wolkwork/obstore](https://github.com/wolkwork/obstore) fork as
`obstore-databaas` on PyPI while the `RemoteSignedS3Store` contribution is under review
upstream. The import name is unchanged, so this is a drop-in replacement for `obstore`.

### New Features :magic_wand:

- Add `RemoteSignedS3Store`, an S3-compatible store that never receives S3 credentials
and instead has every S3 REST request signed by a Python callback immediately before
the request is dispatched. This suits catalogs that vend signatures rather than
credentials, such as Lakekeeper's S3 request signer. Ranged `GET`, `HEAD`, `PUT`
(including conditional create/update, attributes and tags), `DELETE`, server-side
`COPY`, `LIST` and multipart uploads are supported, and every retry is signed afresh.
`RemoteSignedS3Store.from_s3_url(location, signer, endpoint=...)` builds a store from an
`s3://bucket/prefix` location plus the endpoint serving it, which is how catalogs
usually report a location.

## [0.11.0] - 2026-06-25

Expand Down
4 changes: 3 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions docs/api/store/remote-signed-s3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Remote-signed S3

::: obstore.store.RemoteSignedS3Store
options:
inherited_members: true
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ nav:
- obstore.store:
- api/store/index.md
- api/store/aws.md
- api/store/remote-signed-s3.md
- api/store/gcs.md
- api/store/azure.md
- api/store/http.md
Expand Down
2 changes: 1 addition & 1 deletion obstore/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "obstore"
version = "0.11.0"
version = "0.12.1"
authors = { workspace = true }
edition = { workspace = true }
description = "The simplest, highest-throughput interface to Amazon S3, Google Cloud Storage, Azure Blob Storage, and S3-compliant APIs like Cloudflare R2."
Expand Down
2 changes: 1 addition & 1 deletion obstore/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ requires = ["maturin>=1.12.0,<2.0"]
build-backend = "maturin"

[project]
name = "obstore"
name = "obstore-databaas"
requires-python = ">=3.10"
readme = "README.md"
dependencies = ["typing-extensions; python_version < '3.13'"]
Expand Down
11 changes: 10 additions & 1 deletion obstore/python/obstore/_store/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ from ._gcs import GCSCredential as GCSCredential
from ._gcs import GCSCredentialProvider as GCSCredentialProvider
from ._gcs import GCSStore as GCSStore
from ._http import HTTPStore as HTTPStore
from ._remote_signed_s3 import RemoteSignedS3Store as RemoteSignedS3Store
from ._remote_signed_s3 import Signer as Signer
from ._remote_signed_s3 import SignerResult as SignerResult
from ._retry import BackoffConfig as BackoffConfig
from ._retry import RetryConfig as RetryConfig

Expand Down Expand Up @@ -201,7 +204,13 @@ class MemoryStore:
def __init__(self) -> None: ...

ObjectStore: TypeAlias = (
AzureStore | GCSStore | HTTPStore | S3Store | LocalStore | MemoryStore
AzureStore
| GCSStore
| HTTPStore
| RemoteSignedS3Store
| S3Store
| LocalStore
| MemoryStore
)
"""All supported ObjectStore implementations.

Expand Down
122 changes: 122 additions & 0 deletions obstore/python/obstore/_store/_remote_signed_s3.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import sys
from collections.abc import Awaitable, Callable

from ._client import ClientConfig
from ._retry import RetryConfig

if sys.version_info >= (3, 10):
from typing import TypeAlias
else:
from typing_extensions import TypeAlias

if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self

SignerResult: TypeAlias = tuple[str, dict[str, str]]
"""The signed `(uri, headers)` returned by a [`Signer`][obstore.store.Signer]."""

Signer: TypeAlias = Callable[
[str, str, dict[str, str]],
SignerResult | Awaitable[SignerResult],
]
"""A callback that signs a single S3 request.

It is called with `(method, uri, headers)` immediately before each request is
dispatched, and must return the signed `(uri, headers)`. It may be synchronous or
asynchronous.

The headers it returns **replace** the headers it was given, rather than being merged
into them, because a signature only covers the headers the signer chose to sign. A
signer must therefore echo back every header it was passed that the request still needs
(such as `range` or `content-length`) alongside the ones it adds.
"""

class RemoteSignedS3Store:
"""An S3-compatible store that has each request signed by a remote service."""

def __init__(
self,
url: str,
signer: Signer,
*,
virtual_hosted_style_request: bool = False,
client_options: ClientConfig | None = None,
retry_config: RetryConfig | None = None,
) -> None:
"""Construct a new RemoteSignedS3Store.

Args:
url: Base URL including the bucket and optional object prefix.
signer: Callback that signs each request.

Keyword Args:
virtual_hosted_style_request: Whether the bucket is named by the URL's host
rather than its first path segment. Defaults to `False`.
client_options: HTTP client options, such as timeouts and `allow_http`.
retry_config: How to retry failed requests. Every retry is signed again.

"""
@classmethod
def from_s3_url(
cls,
url: str,
signer: Signer,
*,
endpoint: str,
virtual_hosted_style_request: bool = False,
client_options: ClientConfig | None = None,
retry_config: RetryConfig | None = None,
) -> Self:
"""Construct a store from an `s3://` location and the endpoint that serves it.

Catalogs hand out locations as `s3://bucket/key` with the S3 endpoint configured
separately, so this saves you assembling the HTTPS URL yourself.

```py
store = RemoteSignedS3Store.from_s3_url(
"s3://warehouse/zarr/my-array",
signer,
endpoint="https://s3.eu-west-1.amazonaws.com",
)
```

Args:
url: An `s3://bucket/prefix` or `s3a://bucket/prefix` location.
signer: Callback that signs each request.

Keyword Args:
endpoint: The `http://` or `https://` origin of the S3 endpoint serving the
bucket, such as `https://s3.eu-west-1.amazonaws.com`. It must not include
a path, since that would make the split between endpoint, bucket and key
prefix ambiguous; build the URL yourself in that case.
virtual_hosted_style_request: Set to `True` to address the bucket as a
subdomain of `endpoint`'s host rather than as its first path segment.
Defaults to `False`.
client_options: HTTP client options, such as timeouts and `allow_http`.
retry_config: How to retry failed requests. Every retry is signed again.

"""
def __eq__(self, other: object) -> bool: ...
@property
def url(self) -> str:
"""The base URL this store was constructed with."""
@property
def bucket(self) -> str:
"""The bucket name, taken from `url`'s first path segment or its host."""
@property
def prefix(self) -> str | None:
"""The key prefix implied by `url`, or `None` if it names only the bucket."""
@property
def signer(self) -> Signer:
"""The signer callback passed to the constructor."""
@property
def virtual_hosted_style_request(self) -> bool:
"""Whether the bucket is named by the URL's host."""
@property
def client_options(self) -> ClientConfig | None:
"""Get the store's client configuration."""
@property
def retry_config(self) -> RetryConfig | None:
"""Get the store's retry configuration."""
121 changes: 121 additions & 0 deletions obstore/python/obstore/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"HTTPStore",
"LocalStore",
"MemoryStore",
"RemoteSignedS3Store",
"RetryConfig",
"S3Config",
"S3Credential",
Expand Down Expand Up @@ -655,6 +656,125 @@ class MemoryStore(ObjectStoreMethods, _store.MemoryStore):
"""


class RemoteSignedS3Store(ObjectStoreMethods, _store.RemoteSignedS3Store):
"""An S3-compatible store that has each request signed by a remote service.

Unlike [`S3Store`][obstore.store.S3Store], this store never receives or holds S3
credentials. Instead, it constructs each final S3 REST request, hands the request's
method, URI, and headers to a `signer` callback immediately before dispatch, and
sends exactly the URI and headers the callback returns. The store re-signs on every
retry, so signatures may be short-lived.

This suits setups where an external service (for example [Lakekeeper]'s S3 request
signer) authorizes access to a location without vending credentials to the client.

The `signer` contract is:

```py
def signer(
method: str,
uri: str,
headers: dict[str, str],
) -> tuple[str, dict[str, str]]:
...
```

It receives the HTTP method, the fully-constructed request URI (including any query
parameters), and the request headers, and returns the signed `(uri, headers)`. The
callback may be synchronous or asynchronous; any credential/token caching belongs
inside the callback.

!!! note
A synchronous `signer` is required when calling the synchronous store methods
(for example `get_range` or `list().collect()`). Use an asynchronous `signer`
with the `_async` methods.

!!! important "The signer's headers replace, not extend, the request's"
The store sends exactly the headers the callback returns. This matches the S3
remote-signing contract: a signature covers a specific set of headers, so
keeping headers the signer did not return would invalidate it. A signer must
therefore echo back every header it was passed that the request still needs —
`range`, `content-length`, `if-none-match` and so on — alongside the ones it
adds.

The request body is never sent to the signer, so a signer cannot compute a
payload hash and must sign with `x-amz-content-sha256: UNSIGNED-PAYLOAD`.

`GET` (including byte ranges), `HEAD`, `PUT` (with conditional create/overwrite and
object attributes/tags), `DELETE`, server-side `COPY`, `LIST` and multipart uploads
are all supported. Every multipart initiation, part upload, completion and abort is
an independently signed request.

!!! note "Plain HTTP endpoints"
Like the other stores, this one refuses `http://` URLs unless you opt in with
`client_options={"allow_http": True}`.

!!! warning "`LIST` against a path-validating signer"
`LIST` uses S3 `ListObjectsV2`, which issues a request to the bucket root
with the prefix as a *query parameter*
(`GET /bucket?list-type=2&prefix=...`). Signers that authorize by the URL
*path* rather than the query — notably Lakekeeper's generic-table signer —
cannot match the location and reject these requests (e.g.
`NoSuchTableLocationException`).

This affects any operation that lists, including Zarr's `delete_dir`
(triggered by `overwrite=True`). With such a signer, avoid list-based
operations against the signed location: write arrays fresh instead of
overwriting. There is no client-side fix — the prefix cannot be moved into
the URL path without breaking S3 list semantics.

**Example**:

```py
import requests
from obstore.store import RemoteSignedS3Store

def signer(method, uri, headers):
resp = requests.post(
"https://catalog.example.com/sign",
json={"method": method, "uri": uri, "headers": headers},
)
resp.raise_for_status()
signed = resp.json()
return signed["uri"], signed["headers"]

store = RemoteSignedS3Store("https://s3.example.com/bucket/prefix", signer)
data = store.get_range("chunk.bin", start=0, end=1024)
```

Catalogs usually hand out an `s3://` location with the endpoint configured
separately, in which case
[`from_s3_url`][obstore.store.RemoteSignedS3Store.from_s3_url] saves assembling the
URL:

```py
store = RemoteSignedS3Store.from_s3_url(
"s3://warehouse/zarr/my-array",
signer,
endpoint="https://s3.example.com",
)
```

[Lakekeeper]: https://lakekeeper.io/

Args:
url: Base URL including the bucket and optional object prefix, for example
`https://s3.example.com/bucket/prefix`. All paths passed to store methods
are resolved relative to this prefix.
signer: Callable that signs each request, as described above.

Keyword Args:
virtual_hosted_style_request: Set to `True` when the bucket is named by the
URL's host rather than its first path segment, as in
`https://bucket.s3.amazonaws.com/prefix`. Every path segment is then treated
as part of the key prefix. Defaults to `False`.
client_options: HTTP client options, such as timeouts and `allow_http`.
retry_config: How to retry failed requests. Each attempt is signed afresh, so a
retry never reuses an expired signature.

"""


class S3Store(ObjectStoreMethods, _store.S3Store):
"""Interface to an Amazon S3 bucket.

Expand All @@ -679,6 +799,7 @@ class S3Store(ObjectStoreMethods, _store.S3Store):
AzureStore,
GCSStore,
HTTPStore,
RemoteSignedS3Store,
S3Store,
LocalStore,
MemoryStore,
Expand Down
Loading
Loading