2020//! Python-side `ZarrFormatSpec`.
2121
2222use std:: ffi:: CString ;
23- use std:: sync:: Mutex ;
23+ use std:: sync:: { Arc , Mutex , OnceLock } ;
2424
2525use arrow_array:: ffi_stream:: FFI_ArrowArrayStream ;
26+ use object_store:: ObjectStore ;
2627use pyo3:: exceptions:: { PyRuntimeError , PyValueError } ;
2728use pyo3:: prelude:: * ;
2829use pyo3:: types:: PyCapsule ;
29- use sedona_raster_zarr:: ZarrChunkReader ;
30+ use sedona_raster_zarr:: { open_storage_from_uri, ZarrChunkReader } ;
31+ use tokio:: runtime:: { Builder , Runtime } ;
32+ use url:: Url ;
33+
34+ /// Process-wide tokio runtime backing the sync Python FFI bridge.
35+ ///
36+ /// `ZarrChunkReader::try_new` is async, but the Python constructor is sync
37+ /// by contract, so we `block_on` here. Building a runtime per reader is
38+ /// wasteful; one shared runtime serves every open in the package. `next()`
39+ /// on the returned reader is pure CPU and never touches this runtime.
40+ fn shared_runtime ( ) -> & ' static Runtime {
41+ static RUNTIME : OnceLock < Runtime > = OnceLock :: new ( ) ;
42+ RUNTIME . get_or_init ( || {
43+ Builder :: new_current_thread ( )
44+ . enable_all ( )
45+ . build ( )
46+ . expect ( "build sedonadb-zarr tokio runtime" )
47+ } )
48+ }
3049
3150/// Single-use `__arrow_c_stream__` wrapper around `ZarrChunkReader`.
3251#[ pyclass]
@@ -39,7 +58,19 @@ impl PyZarrChunkReader {
3958 #[ new]
4059 #[ pyo3( signature = ( uri, arrays=None , batch_size=8192 ) ) ]
4160 fn new ( uri : & str , arrays : Option < Vec < String > > , batch_size : usize ) -> PyResult < Self > {
42- let reader = ZarrChunkReader :: try_new ( uri, arrays. as_deref ( ) , batch_size)
61+ let store =
62+ default_object_store_for_uri ( uri) . map_err ( |e| PyValueError :: new_err ( e. to_string ( ) ) ) ?;
63+ let storage =
64+ open_storage_from_uri ( uri, store) . map_err ( |e| PyValueError :: new_err ( e. to_string ( ) ) ) ?;
65+ // The crate's async-native loader exposes `try_new` as an
66+ // `async fn`; the Python FFI is a sync constructor by contract,
67+ // so we bridge by blocking on the shared package runtime.
68+ // `next()` on the returned reader is pure CPU and stays synchronous.
69+ let arrays_ref = arrays. as_deref ( ) ;
70+ let reader = shared_runtime ( )
71+ . block_on ( ZarrChunkReader :: try_new (
72+ storage, uri, arrays_ref, batch_size,
73+ ) )
4374 . map_err ( |e| PyValueError :: new_err ( e. to_string ( ) ) ) ?;
4475 Ok ( Self {
4576 inner : Mutex :: new ( Some ( reader) ) ,
@@ -70,6 +101,73 @@ impl PyZarrChunkReader {
70101 }
71102}
72103
104+ /// Build an `Arc<dyn ObjectStore>` for `uri` using env-var credential
105+ /// discovery, per scheme. This is the temporary bridge that lets the
106+ /// Python FFI work without going through `con.read_format`; it
107+ /// reaches into `AWS_*` environment variables via the `object_store`
108+ /// per-backend `Builder::from_env` helpers.
109+ ///
110+ /// Only the schemes exercised by the crate's tests are wired up (`s3`,
111+ /// `http`/`https`); `gcs`/`azure` are intentionally omitted until there
112+ /// is coverage for them.
113+ ///
114+ /// **Slated for removal.** When the host wheel surfaces
115+ /// `args.src.store` to `open_reader` via the `FFI_ObjectStore`
116+ /// capsule machinery (in progress — see
117+ /// <https://github.com/apache/sedona-db/pull/890>), this function (and
118+ /// the `object_store` `aws`/`http` features that back it)
119+ /// gets deleted — the store will arrive credentialed from the host's
120+ /// `ObjectStoreRegistry` and `PyZarrChunkReader::new` will extract it
121+ /// from the capsule instead. For `file://` and bare paths the
122+ /// returned store is a no-op placeholder; the loader uses
123+ /// `FilesystemStore` directly for the local case.
124+ fn default_object_store_for_uri ( uri : & str ) -> Result < Arc < dyn ObjectStore > , PyErr > {
125+ // file:// and bare paths use a LocalFileSystem rooted at `/`;
126+ // open_storage_from_uri prefixes it at the group's path. This
127+ // matches the store the host's ObjectStoreRegistry yields for
128+ // file://, so the loader treats local and cloud uniformly.
129+ if uri. starts_with ( "file://" ) || !uri. contains ( "://" ) {
130+ return Ok ( Arc :: new ( object_store:: local:: LocalFileSystem :: new ( ) ) ) ;
131+ }
132+ let url = Url :: parse ( uri)
133+ . map_err ( |e| PyValueError :: new_err ( format ! ( "group URI {uri:?} is not a valid URL: {e}" ) ) ) ?;
134+ match url. scheme ( ) . to_ascii_lowercase ( ) . as_str ( ) {
135+ "s3" => {
136+ use object_store:: aws:: AmazonS3Builder ;
137+ let store = AmazonS3Builder :: from_env ( )
138+ . with_url ( uri)
139+ . build ( )
140+ . map_err ( |e| PyValueError :: new_err ( build_err ( "s3" , uri, e) ) ) ?;
141+ Ok ( Arc :: new ( store) )
142+ }
143+ "http" | "https" => {
144+ use object_store:: http:: HttpBuilder ;
145+ // open_storage_from_uri applies the path as a PrefixStore,
146+ // so the HttpStore must be rooted at scheme+authority only
147+ // — unlike S3, HttpBuilder roots at whatever URL it's given,
148+ // so hand it the authority without the path.
149+ let authority = format ! ( "{}://{}" , url. scheme( ) , url. authority( ) ) ;
150+ let store = HttpBuilder :: new ( )
151+ . with_url ( authority)
152+ . build ( )
153+ . map_err ( |e| PyValueError :: new_err ( build_err ( "http" , uri, e) ) ) ?;
154+ Ok ( Arc :: new ( store) )
155+ }
156+ other => Err ( PyValueError :: new_err ( format ! (
157+ "unsupported Zarr URI scheme {other:?}; expected one of: \
158+ file, s3, http, https"
159+ ) ) ) ,
160+ }
161+ }
162+
163+ fn build_err ( backend : & str , uri : & str , err : object_store:: Error ) -> String {
164+ format ! (
165+ "failed to build {backend} object_store for {uri}: {err}. \
166+ Provide credentials via standard environment variables \
167+ (e.g. AWS_ACCESS_KEY_ID/AWS_REGION for s3)."
168+ )
169+ }
170+
73171#[ pymodule]
74172fn _lib ( m : & Bound < ' _ , PyModule > ) -> PyResult < ( ) > {
75173 m. add_class :: < PyZarrChunkReader > ( ) ?;
0 commit comments