Skip to content

Commit 4906d39

Browse files
qzyu999timsaucer
andauthored
Add object_store parameter to register/read file methods for thread-safe credentials (#1625)
* Add object_store parameter to register/read file methods Add an optional object_store parameter to register_parquet, read_parquet, register_csv, read_csv, register_json, read_json, register_avro, read_avro, register_arrow, and read_arrow methods. When provided, the store is automatically registered for the URL scheme and host parsed from the path, removing the need to manually call register_object_store separately. This enables thread-safe cloud credential handling without os.environ mutation. Includes unit tests (mock-based) for URL parsing logic and integration tests using LocalFileSystem for end-to-end validation. Closes #1624 * Fix file:// URL handling in _register_object_store_for_path - Allow empty netloc for file:// scheme (e.g. file:///tmp/data.parquet) - Require netloc (host/bucket) only for non-file schemes (s3, gs, az, https) - Add doctest +SKIP to register_parquet example referencing nonexistent file - Use Path.as_uri() in end-to-end tests for portable file:// URL construction - Add tests for file:// acceptance and scheme-without-host rejection * Refactor tests to use pytest.mark.parametrize per reviewer feedback Replace class-based test structure with flat parametrized functions to match the repository's testing conventions. --------- Co-authored-by: Tim Saucer <timsaucer@gmail.com>
1 parent 5cc3230 commit 4906d39

2 files changed

Lines changed: 304 additions & 3 deletions

File tree

python/datafusion/context.py

Lines changed: 164 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@
5454
from typing_extensions import deprecated # Python 3.12
5555

5656

57+
from urllib.parse import urlparse
58+
5759
import pyarrow as pa
5860

5961
from datafusion.catalog import (
@@ -611,6 +613,45 @@ def deregister_object_store(self, schema: str, host: str | None = None) -> None:
611613
"""
612614
self.ctx.deregister_object_store(schema, host)
613615

616+
def _register_object_store_for_path(
617+
self, path: str | pathlib.Path, store: Any
618+
) -> None:
619+
"""Parse a URL path and register the given object store for its scheme and host.
620+
621+
This is a convenience helper used by methods like
622+
:py:meth:`register_parquet` and :py:meth:`read_parquet` to
623+
automatically register an object store when an ``object_store``
624+
parameter is provided.
625+
626+
Args:
627+
path: A URL-style path (e.g. ``"s3://bucket/key.parquet"`` or
628+
``"file:///tmp/data.parquet"``).
629+
store: An object store instance to register.
630+
631+
Raises:
632+
ValueError: If the path does not contain a URL scheme, or if
633+
a non-file scheme is missing a host/bucket component.
634+
"""
635+
parsed = urlparse(str(path))
636+
if not parsed.scheme:
637+
msg = (
638+
f"Cannot determine object store URL from path {path!r}. "
639+
"The path must use a URL scheme (e.g. 's3://bucket/key')."
640+
)
641+
raise ValueError(msg)
642+
# file:// URLs typically have an empty netloc (e.g. file:///tmp/a.parquet)
643+
# For other schemes (s3, gs, az, https) the netloc (bucket/host) is required.
644+
if parsed.scheme != "file" and not parsed.netloc:
645+
msg = (
646+
f"Cannot determine object store URL from path {path!r}. "
647+
"The path must include a host or bucket "
648+
"(e.g. 's3://bucket/key')."
649+
)
650+
raise ValueError(msg)
651+
scheme = f"{parsed.scheme}://"
652+
host = parsed.netloc or None
653+
self.register_object_store(scheme, store, host=host)
654+
614655
def register_listing_table(
615656
self,
616657
name: str,
@@ -1028,6 +1069,7 @@ def register_parquet(
10281069
skip_metadata: bool = True,
10291070
schema: pa.Schema | None = None,
10301071
file_sort_order: Sequence[Sequence[SortKey]] | None = None,
1072+
object_store: Any | None = None,
10311073
) -> None:
10321074
"""Register a Parquet file as a table.
10331075
@@ -1049,7 +1091,41 @@ def register_parquet(
10491091
file_sort_order: Sort order for the file. Each sort key can be
10501092
specified as a column name (``str``), an expression
10511093
(``Expr``), or a ``SortExpr``.
1052-
"""
1094+
object_store: A pre-configured object store instance (e.g.
1095+
:py:class:`~datafusion.object_store.AmazonS3`,
1096+
:py:class:`~datafusion.object_store.GoogleCloud`,
1097+
:py:class:`~datafusion.object_store.MicrosoftAzure`) to use
1098+
for accessing the file. When provided, the store is
1099+
automatically registered for the URL scheme and host parsed
1100+
from ``path``, removing the need to call
1101+
:py:meth:`register_object_store` separately. This is
1102+
especially useful in multi-threaded environments where
1103+
setting credentials via ``os.environ`` is not thread-safe.
1104+
1105+
Examples:
1106+
Register a local Parquet file:
1107+
1108+
>>> import datafusion
1109+
>>> ctx = datafusion.SessionContext()
1110+
>>> ctx.register_parquet("my_table", "data.parquet") # doctest: +SKIP
1111+
1112+
Register from S3 with inline credentials (thread-safe):
1113+
1114+
>>> from datafusion.object_store import AmazonS3 # doctest: +SKIP
1115+
>>> store = AmazonS3(
1116+
... bucket_name="my-bucket",
1117+
... region="us-east-1",
1118+
... access_key_id="...",
1119+
... secret_access_key="...",
1120+
... ) # doctest: +SKIP
1121+
>>> ctx.register_parquet(
1122+
... "my_table",
1123+
... "s3://my-bucket/data.parquet",
1124+
... object_store=store,
1125+
... ) # doctest: +SKIP
1126+
"""
1127+
if object_store is not None:
1128+
self._register_object_store_for_path(path, object_store)
10531129
if table_partition_cols is None:
10541130
table_partition_cols = []
10551131
table_partition_cols = _convert_table_partition_cols(table_partition_cols)
@@ -1075,6 +1151,7 @@ def register_csv(
10751151
file_extension: str = ".csv",
10761152
file_compression_type: str | None = None,
10771153
options: CsvReadOptions | None = None,
1154+
object_store: Any | None = None,
10781155
) -> None:
10791156
"""Register a CSV file as a table.
10801157
@@ -1096,7 +1173,14 @@ def register_csv(
10961173
file_compression_type: File compression type.
10971174
options: Set advanced options for CSV reading. This cannot be
10981175
combined with any of the other options in this method.
1099-
"""
1176+
object_store: A pre-configured object store instance to use for
1177+
accessing the file. When provided, the store is automatically
1178+
registered for the URL scheme and host parsed from ``path``.
1179+
"""
1180+
if object_store is not None:
1181+
# For list paths, register from the first entry
1182+
register_path = path[0] if isinstance(path, list) else path
1183+
self._register_object_store_for_path(register_path, object_store)
11001184
if options is not None and (
11011185
schema is not None
11021186
or not has_header
@@ -1143,6 +1227,7 @@ def register_json(
11431227
file_extension: str = ".json",
11441228
table_partition_cols: list[tuple[str, str | pa.DataType]] | None = None,
11451229
file_compression_type: str | None = None,
1230+
object_store: Any | None = None,
11461231
) -> None:
11471232
"""Register a JSON file as a table.
11481233
@@ -1159,7 +1244,12 @@ def register_json(
11591244
selected for data input.
11601245
table_partition_cols: Partition columns.
11611246
file_compression_type: File compression type.
1247+
object_store: A pre-configured object store instance to use for
1248+
accessing the file. When provided, the store is automatically
1249+
registered for the URL scheme and host parsed from ``path``.
11621250
"""
1251+
if object_store is not None:
1252+
self._register_object_store_for_path(path, object_store)
11631253
if table_partition_cols is None:
11641254
table_partition_cols = []
11651255
table_partition_cols = _convert_table_partition_cols(table_partition_cols)
@@ -1180,6 +1270,7 @@ def register_avro(
11801270
schema: pa.Schema | None = None,
11811271
file_extension: str = ".avro",
11821272
table_partition_cols: list[tuple[str, str | pa.DataType]] | None = None,
1273+
object_store: Any | None = None,
11831274
) -> None:
11841275
"""Register an Avro file as a table.
11851276
@@ -1192,7 +1283,12 @@ def register_avro(
11921283
schema: The data source schema.
11931284
file_extension: File extension to select.
11941285
table_partition_cols: Partition columns.
1286+
object_store: A pre-configured object store instance to use for
1287+
accessing the file. When provided, the store is automatically
1288+
registered for the URL scheme and host parsed from ``path``.
11951289
"""
1290+
if object_store is not None:
1291+
self._register_object_store_for_path(path, object_store)
11961292
if table_partition_cols is None:
11971293
table_partition_cols = []
11981294
table_partition_cols = _convert_table_partition_cols(table_partition_cols)
@@ -1205,6 +1301,7 @@ def register_arrow(
12051301
schema: pa.Schema | None = None,
12061302
file_extension: str = ".arrow",
12071303
table_partition_cols: list[tuple[str, str | pa.DataType]] | None = None,
1304+
object_store: Any | None = None,
12081305
) -> None:
12091306
"""Register an Arrow IPC file as a table.
12101307
@@ -1217,6 +1314,9 @@ def register_arrow(
12171314
schema: The data source schema.
12181315
file_extension: File extension to select.
12191316
table_partition_cols: Partition columns.
1317+
object_store: A pre-configured object store instance to use for
1318+
accessing the file. When provided, the store is automatically
1319+
registered for the URL scheme and host parsed from ``path``.
12201320
12211321
Examples:
12221322
>>> import tempfile, os
@@ -1271,6 +1371,8 @@ def register_arrow(
12711371
30
12721372
]
12731373
"""
1374+
if object_store is not None:
1375+
self._register_object_store_for_path(path, object_store)
12741376
if table_partition_cols is None:
12751377
table_partition_cols = []
12761378
table_partition_cols = _convert_table_partition_cols(table_partition_cols)
@@ -1690,6 +1792,7 @@ def read_json(
16901792
file_extension: str = ".json",
16911793
table_partition_cols: list[tuple[str, str | pa.DataType]] | None = None,
16921794
file_compression_type: str | None = None,
1795+
object_store: Any | None = None,
16931796
) -> DataFrame:
16941797
"""Read a line-delimited JSON data source.
16951798
@@ -1702,10 +1805,15 @@ def read_json(
17021805
selected for data input.
17031806
table_partition_cols: Partition columns.
17041807
file_compression_type: File compression type.
1808+
object_store: A pre-configured object store instance to use for
1809+
accessing the file. When provided, the store is automatically
1810+
registered for the URL scheme and host parsed from ``path``.
17051811
17061812
Returns:
17071813
DataFrame representation of the read JSON files.
17081814
"""
1815+
if object_store is not None:
1816+
self._register_object_store_for_path(path, object_store)
17091817
if table_partition_cols is None:
17101818
table_partition_cols = []
17111819
table_partition_cols = _convert_table_partition_cols(table_partition_cols)
@@ -1731,6 +1839,7 @@ def read_csv(
17311839
table_partition_cols: list[tuple[str, str | pa.DataType]] | None = None,
17321840
file_compression_type: str | None = None,
17331841
options: CsvReadOptions | None = None,
1842+
object_store: Any | None = None,
17341843
) -> DataFrame:
17351844
"""Read a CSV data source.
17361845
@@ -1750,10 +1859,16 @@ def read_csv(
17501859
file_compression_type: File compression type.
17511860
options: Set advanced options for CSV reading. This cannot be
17521861
combined with any of the other options in this method.
1862+
object_store: A pre-configured object store instance to use for
1863+
accessing the file. When provided, the store is automatically
1864+
registered for the URL scheme and host parsed from ``path``.
17531865
17541866
Returns:
17551867
DataFrame representation of the read CSV files
17561868
"""
1869+
if object_store is not None:
1870+
register_path = path[0] if isinstance(path, list) else path
1871+
self._register_object_store_for_path(register_path, object_store)
17571872
if options is not None and (
17581873
schema is not None
17591874
or not has_header
@@ -1803,6 +1918,7 @@ def read_parquet(
18031918
skip_metadata: bool = True,
18041919
schema: pa.Schema | None = None,
18051920
file_sort_order: Sequence[Sequence[SortKey]] | None = None,
1921+
object_store: Any | None = None,
18061922
) -> DataFrame:
18071923
"""Read a Parquet source into a :py:class:`~datafusion.dataframe.Dataframe`.
18081924
@@ -1822,10 +1938,43 @@ def read_parquet(
18221938
file_sort_order: Sort order for the file. Each sort key can be
18231939
specified as a column name (``str``), an expression
18241940
(``Expr``), or a ``SortExpr``.
1941+
object_store: A pre-configured object store instance (e.g.
1942+
:py:class:`~datafusion.object_store.AmazonS3`,
1943+
:py:class:`~datafusion.object_store.GoogleCloud`,
1944+
:py:class:`~datafusion.object_store.MicrosoftAzure`) to use
1945+
for accessing the file. When provided, the store is
1946+
automatically registered for the URL scheme and host parsed
1947+
from ``path``, removing the need to call
1948+
:py:meth:`register_object_store` separately. This is
1949+
especially useful in multi-threaded environments where
1950+
setting credentials via ``os.environ`` is not thread-safe.
18251951
18261952
Returns:
18271953
DataFrame representation of the read Parquet files
1828-
"""
1954+
1955+
Examples:
1956+
Read a local Parquet file:
1957+
1958+
>>> import datafusion
1959+
>>> ctx = datafusion.SessionContext()
1960+
>>> df = ctx.read_parquet("data.parquet") # doctest: +SKIP
1961+
1962+
Read from S3 with inline credentials (thread-safe):
1963+
1964+
>>> from datafusion.object_store import AmazonS3 # doctest: +SKIP
1965+
>>> store = AmazonS3(
1966+
... bucket_name="my-bucket",
1967+
... region="us-east-1",
1968+
... access_key_id="...",
1969+
... secret_access_key="...",
1970+
... ) # doctest: +SKIP
1971+
>>> df = ctx.read_parquet(
1972+
... "s3://my-bucket/data.parquet",
1973+
... object_store=store,
1974+
... ) # doctest: +SKIP
1975+
"""
1976+
if object_store is not None:
1977+
self._register_object_store_for_path(path, object_store)
18291978
if table_partition_cols is None:
18301979
table_partition_cols = []
18311980
table_partition_cols = _convert_table_partition_cols(table_partition_cols)
@@ -1848,6 +1997,7 @@ def read_avro(
18481997
schema: pa.Schema | None = None,
18491998
file_partition_cols: list[tuple[str, str | pa.DataType]] | None = None,
18501999
file_extension: str = ".avro",
2000+
object_store: Any | None = None,
18512001
) -> DataFrame:
18522002
"""Create a :py:class:`DataFrame` for reading Avro data source.
18532003
@@ -1856,10 +2006,15 @@ def read_avro(
18562006
schema: The data source schema.
18572007
file_partition_cols: Partition columns.
18582008
file_extension: File extension to select.
2009+
object_store: A pre-configured object store instance to use for
2010+
accessing the file. When provided, the store is automatically
2011+
registered for the URL scheme and host parsed from ``path``.
18592012
18602013
Returns:
18612014
DataFrame representation of the read Avro file
18622015
"""
2016+
if object_store is not None:
2017+
self._register_object_store_for_path(path, object_store)
18632018
if file_partition_cols is None:
18642019
file_partition_cols = []
18652020
file_partition_cols = _convert_table_partition_cols(file_partition_cols)
@@ -1873,6 +2028,7 @@ def read_arrow(
18732028
schema: pa.Schema | None = None,
18742029
file_extension: str = ".arrow",
18752030
file_partition_cols: list[tuple[str, str | pa.DataType]] | None = None,
2031+
object_store: Any | None = None,
18762032
) -> DataFrame:
18772033
"""Create a :py:class:`DataFrame` for reading an Arrow IPC data source.
18782034
@@ -1881,6 +2037,9 @@ def read_arrow(
18812037
schema: The data source schema.
18822038
file_extension: File extension to select.
18832039
file_partition_cols: Partition columns.
2040+
object_store: A pre-configured object store instance to use for
2041+
accessing the file. When provided, the store is automatically
2042+
registered for the URL scheme and host parsed from ``path``.
18842043
18852044
Returns:
18862045
DataFrame representation of the read Arrow IPC file.
@@ -1932,6 +2091,8 @@ def read_arrow(
19322091
3
19332092
]
19342093
"""
2094+
if object_store is not None:
2095+
self._register_object_store_for_path(path, object_store)
19352096
if file_partition_cols is None:
19362097
file_partition_cols = []
19372098
file_partition_cols = _convert_table_partition_cols(file_partition_cols)

0 commit comments

Comments
 (0)