Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### What's Changed

* feat: Expose `proxy_excludes`, `proxy_ca_certificate`, and `root_certificate` client options

## [0.10.1] - 2026-06-09

### What's Changed
Expand Down
28 changes: 27 additions & 1 deletion obstore/python/obstore/_store/_client.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,33 @@ class ClientConfig(TypedDict, total=False):
pool_max_idle_per_host: str
"""Maximum number of idle connections per host."""
proxy_url: str
"""HTTP proxy to use for requests."""
proxy_ca_certificate: str
"""PEM-formatted CA certificate for the proxy set via `proxy_url`.

This is the certificate that signs the proxy's own TLS certificate, used to
trust an HTTPS proxy fronted by a private CA. Only consulted when `proxy_url`
is set.
"""
proxy_excludes: str
"""Comma-separated list of hosts that bypass `proxy_url` (`NO_PROXY` semantics).

Each entry may be a hostname, a domain suffix (e.g. `.internal.example.com`),
an IP address, or a CIDR block, for example
`"localhost,127.0.0.1,.svc.cluster.local"`. Only takes effect when `proxy_url`
is set.
"""
root_certificate: str | bytes
"""A custom root CA certificate to trust for TLS, as PEM `str` or `bytes`.

Use this to reach stores fronted by a private CA (e.g. a self-hosted MinIO)
without disabling verification through `allow_invalid_certificates`. The value
may concatenate several certificates; all are parsed.

The certificate(s) are added to the trusted roots rather than replacing them,
so the system roots remain in effect and public endpoints continue to
validate alongside the custom CA.
"""

randomize_addresses: bool
"""Randomize order addresses that the DNS resolution yields.
Expand All @@ -118,7 +145,6 @@ class ClientConfig(TypedDict, total=False):
Default is disabled (no read timeout).
"""

"""HTTP proxy to use for requests."""
timeout: str | timedelta
"""Set timeout for the overall request

Expand Down
82 changes: 76 additions & 6 deletions pyo3-object_store/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,67 @@ use std::collections::HashMap;
use std::str::FromStr;

use http::{HeaderMap, HeaderName, HeaderValue};
use object_store::{ClientConfigKey, ClientOptions};
use object_store::{Certificate, ClientConfigKey, ClientOptions};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use pyo3::pybacked::{PyBackedBytes, PyBackedStr};
use pyo3::types::{PyDict, PyString};
use pyo3::types::{PyBytes, PyDict, PyString};

use crate::config::PyConfigValue;
use crate::error::PyObjectStoreError;
use crate::PyObjectStoreResult;

/// A wrapper around one or more `Certificate`s parsed from PEM input.
///
/// The original PEM is retained so the value round-trips through
/// [`IntoPyObject`]; parsing happens once, on extraction.
Comment on lines +17 to +18

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

👍 I see, it looks like there's no method on Certificate to go back to a PEM.

#[derive(Clone, Debug)]
struct PyCertificate {
pem: Vec<u8>,
certificates: Vec<Certificate>,
}
Comment thread
kylebarron marked this conversation as resolved.

impl PyCertificate {
fn new(pem: Vec<u8>) -> PyObjectStoreResult<Self> {
let certificates = Certificate::from_pem_bundle(&pem)?;
if certificates.is_empty() {
return Err(PyValueError::new_err(
"No certificates found in `root_certificate` input; expected one or more PEM-encoded certificates.",
)
.into());
}
Ok(Self { pem, certificates })
}
}

impl PartialEq for PyCertificate {
fn eq(&self, other: &Self) -> bool {
self.pem == other.pem
}
}

impl<'py> FromPyObject<'_, 'py> for PyCertificate {
type Error = PyErr;

fn extract(obj: Borrowed<'_, 'py, pyo3::PyAny>) -> PyResult<Self> {
let pem = if let Ok(bytes) = obj.extract::<Vec<u8>>() {
bytes
} else {
obj.extract::<String>()?.into_bytes()
};
Ok(Self::new(pem)?)
}
}

impl<'py> IntoPyObject<'py> for &PyCertificate {
type Target = PyBytes;
type Output = Bound<'py, PyBytes>;
type Error = PyErr;

fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
Ok(PyBytes::new(py, &self.pem))
}
}

/// A wrapper around `ClientConfigKey` that implements [`FromPyObject`].
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
Expand Down Expand Up @@ -50,6 +103,7 @@ impl<'py> IntoPyObject<'py> for &PyClientConfigKey {
pub struct PyClientOptions {
string_options: HashMap<PyClientConfigKey, PyConfigValue>,
default_headers: Option<PyHeaderMap>,
root_certificate: Option<PyCertificate>,
}

impl<'py> FromPyObject<'_, 'py> for PyClientOptions {
Expand All @@ -60,23 +114,27 @@ impl<'py> FromPyObject<'_, 'py> for PyClientOptions {
let dict = obj.extract::<Bound<PyDict>>()?;
let mut string_options = HashMap::new();
let mut default_headers = None;
let mut root_certificate = None;

for (key, value) in dict.iter() {
if let Ok(key) = key.extract::<PyClientConfigKey>() {
string_options.insert(key, value.extract::<PyConfigValue>()?);
} else {
let key = key.extract::<PyBackedStr>()?;
if &key == "default_headers" {
default_headers = Some(value.extract::<PyHeaderMap>()?);
} else {
return Err(PyValueError::new_err(format!("Invalid key: {key}.")));
match &*key {
"default_headers" => default_headers = Some(value.extract::<PyHeaderMap>()?),
"root_certificate" => {
root_certificate = Some(value.extract::<PyCertificate>()?)
}
_ => return Err(PyValueError::new_err(format!("Invalid key: {key}."))),
}
}
}

Ok(Self {
string_options,
default_headers,
root_certificate,
})
}
}
Expand All @@ -91,6 +149,9 @@ impl<'py> IntoPyObject<'py> for PyClientOptions {
if let Some(headers) = self.default_headers {
dict.set_item("default_headers", headers)?;
}
if let Some(certificate) = &self.root_certificate {
dict.set_item("root_certificate", certificate)?;
}
Ok(dict)
}
}
Expand All @@ -105,6 +166,9 @@ impl<'py> IntoPyObject<'py> for &PyClientOptions {
if let Some(headers) = &self.default_headers {
dict.set_item("default_headers", headers)?;
}
if let Some(certificate) = &self.root_certificate {
dict.set_item("root_certificate", certificate)?;
}
Ok(dict.clone())
}
}
Expand All @@ -120,6 +184,12 @@ impl From<PyClientOptions> for ClientOptions {
options = options.with_default_headers(headers.0);
}

if let Some(certificate) = value.root_certificate {
for certificate in certificate.certificates {
options = options.with_root_certificate(certificate);
}
}

options
}
}
Expand Down
133 changes: 133 additions & 0 deletions tests/store/test_client_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import pickle

import pytest

from obstore.exceptions import BaseError
from obstore.store import HTTPStore

# Self-signed CAs generated with:
# openssl req -x509 -newkey rsa:2048 -nodes -days 36500 -subj /CN=obstore-test-ca-N
CERT_1 = b"""-----BEGIN CERTIFICATE-----
MIIDGzCCAgOgAwIBAgIUBqLgQSw4wiW06IhiKHNN5NyztG8wDQYJKoZIhvcNAQEL
BQAwHDEaMBgGA1UEAwwRb2JzdG9yZS10ZXN0LWNhLTEwIBcNMjYwNjE2MTQxMjEz
WhgPMjEyNjA1MjMxNDEyMTNaMBwxGjAYBgNVBAMMEW9ic3RvcmUtdGVzdC1jYS0x
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxw/NGsR0jwDKOSyXsfeT
WvniRh+bozmvQuyxW/qZwuNrgrkPdZbSM3j3eaqFOa9iqSds17u3bTYnC08SGyjo
hecOuja2KP5lYUJ31Vfad4KlkYVuNBjQ3FZb71jbwXuwKnhcrYlMUM8Vt/Oay/q+
uKDG0kMXTdjXButCFE+s8oTk0dlGEjQ6IuI3/0l5cv631iMIaIW13CDG1mMJwu9Q
yco8/J6aK6ZQFa0T9TRm2v3Y/3qiX+WryQakX72IY9DbRtxwjh0Lm81M46DChsE3
FyZB69aaKPdSkWUmNAWBa3H8cQziUv2SLz6DAG7r5cJZ0xkbqgiaQR8P1ud8yeL+
LwIDAQABo1MwUTAdBgNVHQ4EFgQUJGhd1ORfcw1Ov3/fljWfEoUJSx8wHwYDVR0j
BBgwFoAUJGhd1ORfcw1Ov3/fljWfEoUJSx8wDwYDVR0TAQH/BAUwAwEB/zANBgkq
hkiG9w0BAQsFAAOCAQEAurAH4Gvomh4QffpZf6s3/TPXHGM2wuuuOKB7QD7sfp8V
nlFthbcpEd0SRaH2T4fIeVDuHcMx/F7GYBaVOjXqRq9N6+zeutpPvu7YAoE4zbLz
Wnrn2fZG5uAO+HW26QXOsZU2zJHpHzZWZ3G1dV/C97k8hmSjkH7OOZSlZ/qIN4+p
5t5qBUJek66luxnEyfOdFitiJe9Ri6sj2ffT0aXejZCu2boO+3Szm6boLu3kCC7l
Latj8uDxEm6HsD4gxn61zMmmSjitTJYJt+lW8+3tlSf17tXd1TLG/cF/0gD28OhL
UmOHI1HSNtVKwotBkpOrlby1hWOX7IMtqPRmEJzz6Q==
-----END CERTIFICATE-----
"""
Comment on lines +10 to +29

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just for my knowledge, how were these test cases defined? Is this random input?

@louisnow louisnow Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I generated them using openssl, I've added the command as a comment now

openssl req -x509 -newkey rsa:2048 -nodes -days 36500 -subj /CN=obstore-test-ca-N

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Cool, thanks. I've never touched custom certificates myself


CERT_2 = b"""-----BEGIN CERTIFICATE-----
MIIDGzCCAgOgAwIBAgIUTxck2vXxjoPXiA51CCIuOodX9AMwDQYJKoZIhvcNAQEL
BQAwHDEaMBgGA1UEAwwRb2JzdG9yZS10ZXN0LWNhLTIwIBcNMjYwNjE2MTQxMjEz
WhgPMjEyNjA1MjMxNDEyMTNaMBwxGjAYBgNVBAMMEW9ic3RvcmUtdGVzdC1jYS0y
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAswXoqgwFuG/CD9IYB+Jt
mCU7gPinn3XLUNVo9K+8Z+C1AvZvuW0EIxmbE+vUjM3u0PbNf0auATOdfIeT30uo
Rr69C1Qu56I5gJP8nWPscDXyEyMOSDxlhSycObwlKM9dlCIz3fwYlU5ukPw+e+tU
kBPuHFbtElXM4u68BdTTjWNZzA4xplSFc5RCcrNnhYZPCINPRRkrjA27F7o1lwQ+
ibzg5CquFSnDdojN8edzIE+xGLznOpTiuu17VqljIkT1bxm9qUZ5fOxsKfTHMfQ2
gBM7lzF1suVWjhCjxKtJwd5JWQyo/ynR2FsAMfxtUXH1GVYrfEm6RUAQQ2hnzFue
wwIDAQABo1MwUTAdBgNVHQ4EFgQU/48jUGMAG9VCesLJ7lSh5TOs+wgwHwYDVR0j
BBgwFoAU/48jUGMAG9VCesLJ7lSh5TOs+wgwDwYDVR0TAQH/BAUwAwEB/zANBgkq
hkiG9w0BAQsFAAOCAQEAiJvYmLrqG/bMWyb3u77tLSfwxGftIo5HApgKt6vgtUZr
0qhgf1scto1lIworYX+MJe/dqHVZC6IjiXOO77HCJlOQoPd3byvV+fu3sU1P79ah
tIFKyRN6cGfAXlPYhR3oB30KEHIMZKve/Y9c1IGv7cogO0/VDXXTe3YcWDLtjzd8
2wGP3vlKhbzV2ZigRtO37fCTBGJRlpmUT+KWQ5roSSbutyYNhAAAOMoM+M6uD4Mm
H3ZwuubjcsNBfC/YQabjFuDrIUAYUS7CWDVv1Qi/26PlRQJEFoVayDT4aO86cAop
Tg1+pQESP0OOub4TswJ0qs0Xx2umJdos/XOaf3xfpQ==
-----END CERTIFICATE-----
"""

CERT_BUNDLE = CERT_1 + CERT_2
Comment thread
kylebarron marked this conversation as resolved.


def test_proxy_excludes():
HTTPStore.from_url(
"https://example.com",
client_options={
"proxy_url": "https://proxy.example.com:8080",
"proxy_excludes": "localhost,127.0.0.1,.svc.cluster.local",
},
)


def test_proxy_ca_certificate():
HTTPStore.from_url(
"https://example.com",
client_options={
"proxy_url": "https://proxy.example.com:8080",
"proxy_ca_certificate": CERT_1.decode(),
},
)


def test_root_certificate_bytes():
HTTPStore.from_url(
"https://example.com",
client_options={"root_certificate": CERT_1},
)


def test_root_certificate_str():
HTTPStore.from_url(
"https://example.com",
client_options={"root_certificate": CERT_1.decode()},
)


def test_root_certificate_bundle():
HTTPStore.from_url(
"https://example.com",
client_options={"root_certificate": CERT_BUNDLE},
)


def test_root_certificate_additive_to_public_host():
HTTPStore.from_url(
"https://s3.amazonaws.com",
client_options={"root_certificate": CERT_BUNDLE},
)


def test_root_certificate_no_pem_blocks_raises():
# Input that contains no PEM blocks parses to zero certificates; rather than
# silently trusting nothing, this should surface as an error.
with pytest.raises(ValueError, match="No certificates found"):
HTTPStore.from_url(
"https://example.com",
client_options={"root_certificate": b"not a real certificate"},
)


def test_root_certificate_malformed_block_raises():
with pytest.raises(BaseError):
HTTPStore.from_url(
"https://example.com",
client_options={
"root_certificate": (
b"-----BEGIN CERTIFICATE-----\n"
b"not-valid-base64!!!\n"
b"-----END CERTIFICATE-----\n"
),
},
)


def test_root_certificate_roundtrip_pickle():
store = HTTPStore.from_url(
"https://example.com",
client_options={"root_certificate": CERT_BUNDLE},
)
restored: HTTPStore = pickle.loads(pickle.dumps(store))
assert restored.url == store.url
Loading