|
22 | 22 | from unittest.mock import MagicMock, patch |
23 | 23 |
|
24 | 24 | import pyarrow as pa |
| 25 | +import pyarrow.parquet as pq |
25 | 26 | import pytest |
26 | 27 | from datafusion import SessionContext |
| 28 | +from datafusion.object_store import LocalFileSystem |
27 | 29 |
|
28 | 30 |
|
29 | 31 | @pytest.fixture |
30 | 32 | def ctx(): |
31 | 33 | return SessionContext() |
32 | 34 |
|
33 | 35 |
|
34 | | -class TestRegisterObjectStoreForPath: |
35 | | - """Unit tests for _register_object_store_for_path URL parsing logic.""" |
36 | | - |
37 | | - def test_parses_s3_url(self, ctx): |
38 | | - mock_store = MagicMock() |
39 | | - with patch.object(ctx, "register_object_store") as mock_register: |
40 | | - ctx._register_object_store_for_path( |
41 | | - "s3://my-bucket/path/to/file.parquet", mock_store |
42 | | - ) |
43 | | - mock_register.assert_called_once_with("s3://", mock_store, host="my-bucket") |
44 | | - |
45 | | - def test_parses_gs_url(self, ctx): |
46 | | - mock_store = MagicMock() |
47 | | - with patch.object(ctx, "register_object_store") as mock_register: |
48 | | - ctx._register_object_store_for_path( |
49 | | - "gs://my-gcs-bucket/data.parquet", mock_store |
50 | | - ) |
51 | | - mock_register.assert_called_once_with( |
52 | | - "gs://", mock_store, host="my-gcs-bucket" |
53 | | - ) |
54 | | - |
55 | | - def test_parses_az_url(self, ctx): |
56 | | - mock_store = MagicMock() |
57 | | - with patch.object(ctx, "register_object_store") as mock_register: |
58 | | - ctx._register_object_store_for_path( |
59 | | - "az://my-container/data.parquet", mock_store |
60 | | - ) |
61 | | - mock_register.assert_called_once_with( |
62 | | - "az://", mock_store, host="my-container" |
63 | | - ) |
64 | | - |
65 | | - def test_parses_https_url(self, ctx): |
66 | | - mock_store = MagicMock() |
67 | | - with patch.object(ctx, "register_object_store") as mock_register: |
68 | | - ctx._register_object_store_for_path( |
69 | | - "https://my-host.example.com/data.parquet", mock_store |
70 | | - ) |
71 | | - mock_register.assert_called_once_with( |
72 | | - "https://", mock_store, host="my-host.example.com" |
73 | | - ) |
74 | | - |
75 | | - def test_raises_on_local_path(self, ctx): |
76 | | - mock_store = MagicMock() |
77 | | - with pytest.raises(ValueError, match="Cannot determine object store URL"): |
78 | | - ctx._register_object_store_for_path("/local/path/file.parquet", mock_store) |
79 | | - |
80 | | - def test_raises_on_relative_path(self, ctx): |
81 | | - mock_store = MagicMock() |
82 | | - with pytest.raises(ValueError, match="Cannot determine object store URL"): |
83 | | - ctx._register_object_store_for_path("relative/path.parquet", mock_store) |
84 | | - |
85 | | - def test_raises_on_windows_path(self, ctx): |
86 | | - mock_store = MagicMock() |
87 | | - with pytest.raises(ValueError, match="must include a host or bucket"): |
88 | | - ctx._register_object_store_for_path( |
89 | | - "C:\\Users\\data\\file.parquet", mock_store |
90 | | - ) |
91 | | - |
92 | | - def test_raises_on_scheme_without_host_for_non_file(self, ctx): |
93 | | - """Non-file schemes (s3, gs, etc.) require a host/bucket.""" |
94 | | - mock_store = MagicMock() |
95 | | - with pytest.raises(ValueError, match="must include a host or bucket"): |
96 | | - ctx._register_object_store_for_path("s3:///key.parquet", mock_store) |
97 | | - |
98 | | - def test_parses_file_url(self, ctx): |
99 | | - """file:// URLs with empty netloc should be accepted.""" |
100 | | - mock_store = MagicMock() |
101 | | - with patch.object(ctx, "register_object_store") as mock_register: |
102 | | - ctx._register_object_store_for_path( |
103 | | - "file:///tmp/path/to/file.parquet", mock_store |
104 | | - ) |
105 | | - mock_register.assert_called_once_with("file://", mock_store, host=None) |
106 | | - |
107 | | - def test_accepts_pathlib_path_raises(self, ctx): |
108 | | - """pathlib.Path cannot represent URLs, so this should raise.""" |
109 | | - mock_store = MagicMock() |
110 | | - # pathlib.Path strips the scheme, so this becomes a local path |
111 | | - with pytest.raises(ValueError, match="Cannot determine object store URL"): |
112 | | - ctx._register_object_store_for_path(Path("/local/file.parquet"), mock_store) |
113 | | - |
114 | | - |
115 | | -class TestRegisterParquetObjectStore: |
116 | | - """Tests for register_parquet with object_store parameter.""" |
117 | | - |
118 | | - def test_object_store_none_does_not_register(self, ctx): |
119 | | - """When object_store is None, register_object_store is not called.""" |
120 | | - with patch.object(ctx, "register_object_store") as mock_register: |
121 | | - # This will fail at the Rust level (file doesn't exist), but |
122 | | - # we're testing that register_object_store is NOT called |
123 | | - with contextlib.suppress(Exception): |
124 | | - ctx.register_parquet("t", "s3://bucket/file.parquet") |
125 | | - mock_register.assert_not_called() |
126 | | - |
127 | | - def test_object_store_triggers_registration(self, ctx): |
128 | | - """When object_store is provided, register_object_store is called.""" |
129 | | - mock_store = MagicMock() |
130 | | - with patch.object(ctx, "register_object_store") as mock_register: |
131 | | - with contextlib.suppress(Exception): |
132 | | - ctx.register_parquet( |
133 | | - "t", |
134 | | - "s3://my-bucket/file.parquet", |
135 | | - object_store=mock_store, |
136 | | - ) |
137 | | - mock_register.assert_called_once_with("s3://", mock_store, host="my-bucket") |
138 | | - |
139 | | - def test_object_store_invalid_path_raises(self, ctx): |
140 | | - """Providing object_store with a local path raises ValueError.""" |
141 | | - mock_store = MagicMock() |
142 | | - with pytest.raises(ValueError, match="Cannot determine object store URL"): |
143 | | - ctx.register_parquet("t", "/local/file.parquet", object_store=mock_store) |
144 | | - |
145 | | - |
146 | | -class TestReadParquetObjectStore: |
147 | | - """Tests for read_parquet with object_store parameter.""" |
148 | | - |
149 | | - def test_object_store_triggers_registration(self, ctx): |
150 | | - mock_store = MagicMock() |
151 | | - with patch.object(ctx, "register_object_store") as mock_register: |
152 | | - with contextlib.suppress(Exception): |
153 | | - ctx.read_parquet("s3://my-bucket/file.parquet", object_store=mock_store) |
154 | | - mock_register.assert_called_once_with("s3://", mock_store, host="my-bucket") |
155 | | - |
156 | | - |
157 | | -class TestRegisterCsvObjectStore: |
158 | | - """Tests for register_csv with object_store parameter.""" |
159 | | - |
160 | | - def test_object_store_triggers_registration(self, ctx): |
161 | | - mock_store = MagicMock() |
162 | | - with patch.object(ctx, "register_object_store") as mock_register: |
163 | | - with contextlib.suppress(Exception): |
164 | | - ctx.register_csv( |
165 | | - "t", "s3://my-bucket/data.csv", object_store=mock_store |
166 | | - ) |
167 | | - mock_register.assert_called_once_with("s3://", mock_store, host="my-bucket") |
168 | | - |
169 | | - def test_object_store_with_list_path(self, ctx): |
170 | | - """For list paths, the first entry is used for URL parsing.""" |
171 | | - mock_store = MagicMock() |
172 | | - with patch.object(ctx, "register_object_store") as mock_register: |
173 | | - with contextlib.suppress(Exception): |
174 | | - ctx.register_csv( |
175 | | - "t", |
176 | | - ["s3://my-bucket/a.csv", "s3://my-bucket/b.csv"], |
177 | | - object_store=mock_store, |
178 | | - ) |
179 | | - mock_register.assert_called_once_with("s3://", mock_store, host="my-bucket") |
180 | | - |
181 | | - |
182 | | -class TestReadCsvObjectStore: |
183 | | - """Tests for read_csv with object_store parameter.""" |
184 | | - |
185 | | - def test_object_store_triggers_registration(self, ctx): |
186 | | - mock_store = MagicMock() |
187 | | - with patch.object(ctx, "register_object_store") as mock_register: |
188 | | - with contextlib.suppress(Exception): |
189 | | - ctx.read_csv("gs://bucket/data.csv", object_store=mock_store) |
190 | | - mock_register.assert_called_once_with("gs://", mock_store, host="bucket") |
191 | | - |
192 | | - |
193 | | -class TestRegisterJsonObjectStore: |
194 | | - """Tests for register_json with object_store parameter.""" |
195 | | - |
196 | | - def test_object_store_triggers_registration(self, ctx): |
197 | | - mock_store = MagicMock() |
198 | | - with patch.object(ctx, "register_object_store") as mock_register: |
199 | | - with contextlib.suppress(Exception): |
200 | | - ctx.register_json("t", "s3://bucket/data.json", object_store=mock_store) |
201 | | - mock_register.assert_called_once_with("s3://", mock_store, host="bucket") |
202 | | - |
203 | | - |
204 | | -class TestReadJsonObjectStore: |
205 | | - """Tests for read_json with object_store parameter.""" |
206 | | - |
207 | | - def test_object_store_triggers_registration(self, ctx): |
208 | | - mock_store = MagicMock() |
209 | | - with patch.object(ctx, "register_object_store") as mock_register: |
210 | | - with contextlib.suppress(Exception): |
211 | | - ctx.read_json("s3://bucket/data.json", object_store=mock_store) |
212 | | - mock_register.assert_called_once_with("s3://", mock_store, host="bucket") |
213 | | - |
214 | | - |
215 | | -class TestRegisterAvroObjectStore: |
216 | | - """Tests for register_avro with object_store parameter.""" |
217 | | - |
218 | | - def test_object_store_triggers_registration(self, ctx): |
219 | | - mock_store = MagicMock() |
220 | | - with patch.object(ctx, "register_object_store") as mock_register: |
221 | | - with contextlib.suppress(Exception): |
222 | | - ctx.register_avro("t", "s3://bucket/data.avro", object_store=mock_store) |
223 | | - mock_register.assert_called_once_with("s3://", mock_store, host="bucket") |
224 | | - |
225 | | - |
226 | | -class TestReadAvroObjectStore: |
227 | | - """Tests for read_avro with object_store parameter.""" |
228 | | - |
229 | | - def test_object_store_triggers_registration(self, ctx): |
230 | | - mock_store = MagicMock() |
231 | | - with patch.object(ctx, "register_object_store") as mock_register: |
232 | | - with contextlib.suppress(Exception): |
233 | | - ctx.read_avro("s3://bucket/data.avro", object_store=mock_store) |
234 | | - mock_register.assert_called_once_with("s3://", mock_store, host="bucket") |
235 | | - |
236 | | - |
237 | | -class TestRegisterArrowObjectStore: |
238 | | - """Tests for register_arrow with object_store parameter.""" |
239 | | - |
240 | | - def test_object_store_triggers_registration(self, ctx): |
241 | | - mock_store = MagicMock() |
242 | | - with patch.object(ctx, "register_object_store") as mock_register: |
243 | | - with contextlib.suppress(Exception): |
244 | | - ctx.register_arrow( |
245 | | - "t", "s3://bucket/data.arrow", object_store=mock_store |
246 | | - ) |
247 | | - mock_register.assert_called_once_with("s3://", mock_store, host="bucket") |
248 | | - |
249 | | - |
250 | | -class TestReadArrowObjectStore: |
251 | | - """Tests for read_arrow with object_store parameter.""" |
252 | | - |
253 | | - def test_object_store_triggers_registration(self, ctx): |
254 | | - mock_store = MagicMock() |
255 | | - with patch.object(ctx, "register_object_store") as mock_register: |
256 | | - with contextlib.suppress(Exception): |
257 | | - ctx.read_arrow("s3://bucket/data.arrow", object_store=mock_store) |
258 | | - mock_register.assert_called_once_with("s3://", mock_store, host="bucket") |
259 | | - |
260 | | - |
261 | | -class TestEndToEndWithLocalFileSystem: |
262 | | - """Integration test using LocalFileSystem object store with register_parquet.""" |
263 | | - |
264 | | - def test_register_parquet_with_local_object_store(self, ctx, tmp_path): |
265 | | - """Verify the full flow works with a real object store and local file.""" |
266 | | - import pyarrow.parquet as pq |
267 | | - from datafusion.object_store import LocalFileSystem |
268 | | - |
269 | | - # Write a test parquet file |
270 | | - table = pa.table({"x": [1, 2, 3], "y": ["a", "b", "c"]}) |
271 | | - parquet_path = tmp_path / "test.parquet" |
272 | | - pq.write_table(table, str(parquet_path)) |
273 | | - |
274 | | - # Use file:// URL with LocalFileSystem object store |
275 | | - store = LocalFileSystem() |
276 | | - file_url = parquet_path.as_uri() |
277 | | - |
278 | | - ctx.register_parquet("test_tbl", file_url, object_store=store) |
279 | | - result = ctx.sql("SELECT * FROM test_tbl").collect() |
280 | | - |
281 | | - assert len(result) == 1 |
282 | | - assert result[0].num_rows == 3 |
283 | | - assert result[0].column("x").to_pylist() == [1, 2, 3] |
284 | | - |
285 | | - def test_read_parquet_with_local_object_store(self, ctx, tmp_path): |
286 | | - """Verify read_parquet works with object_store parameter.""" |
287 | | - import pyarrow.parquet as pq |
288 | | - from datafusion.object_store import LocalFileSystem |
289 | | - |
290 | | - table = pa.table({"val": [10, 20, 30]}) |
291 | | - parquet_path = tmp_path / "read_test.parquet" |
292 | | - pq.write_table(table, str(parquet_path)) |
293 | | - |
294 | | - store = LocalFileSystem() |
295 | | - file_url = parquet_path.as_uri() |
296 | | - |
297 | | - df = ctx.read_parquet(file_url, object_store=store) |
298 | | - result = df.collect() |
299 | | - |
300 | | - assert len(result) == 1 |
301 | | - assert result[0].column("val").to_pylist() == [10, 20, 30] |
| 36 | +@pytest.mark.parametrize( |
| 37 | + ("path", "scheme", "host"), |
| 38 | + [ |
| 39 | + ("s3://my-bucket/path/file.parquet", "s3://", "my-bucket"), |
| 40 | + ("gs://my-gcs-bucket/data.parquet", "gs://", "my-gcs-bucket"), |
| 41 | + ("az://my-container/data.parquet", "az://", "my-container"), |
| 42 | + ("https://example.com/data.parquet", "https://", "example.com"), |
| 43 | + ("file:///tmp/data.parquet", "file://", None), |
| 44 | + ], |
| 45 | +) |
| 46 | +def test_register_object_store_for_url(ctx, path, scheme, host): |
| 47 | + store = MagicMock() |
| 48 | + |
| 49 | + with patch.object(ctx, "register_object_store") as register: |
| 50 | + ctx._register_object_store_for_path(path, store) |
| 51 | + |
| 52 | + register.assert_called_once_with(scheme, store, host=host) |
| 53 | + |
| 54 | + |
| 55 | +@pytest.mark.parametrize( |
| 56 | + ("path", "error"), |
| 57 | + [ |
| 58 | + ("/local/path/file.parquet", "Cannot determine object store URL"), |
| 59 | + ("relative/path.parquet", "Cannot determine object store URL"), |
| 60 | + (Path("/local/file.parquet"), "Cannot determine object store URL"), |
| 61 | + ("C:\\Users\\data\\file.parquet", "must include a host or bucket"), |
| 62 | + ("s3:///key.parquet", "must include a host or bucket"), |
| 63 | + ], |
| 64 | +) |
| 65 | +def test_register_object_store_rejects_invalid_url(path, error, ctx): |
| 66 | + with pytest.raises(ValueError, match=error): |
| 67 | + ctx._register_object_store_for_path(path, MagicMock()) |
| 68 | + |
| 69 | + |
| 70 | +@pytest.mark.parametrize( |
| 71 | + ("method_name", "args", "path"), |
| 72 | + [ |
| 73 | + ("register_parquet", ("table",), "s3://bucket/data.parquet"), |
| 74 | + ("read_parquet", (), "s3://bucket/data.parquet"), |
| 75 | + ("register_csv", ("table",), "s3://bucket/data.csv"), |
| 76 | + ("read_csv", (), "s3://bucket/data.csv"), |
| 77 | + ("register_json", ("table",), "s3://bucket/data.json"), |
| 78 | + ("read_json", (), "s3://bucket/data.json"), |
| 79 | + ("register_avro", ("table",), "s3://bucket/data.avro"), |
| 80 | + ("read_avro", (), "s3://bucket/data.avro"), |
| 81 | + ("register_arrow", ("table",), "s3://bucket/data.arrow"), |
| 82 | + ("read_arrow", (), "s3://bucket/data.arrow"), |
| 83 | + ], |
| 84 | +) |
| 85 | +def test_file_methods_register_object_store(ctx, method_name, args, path): |
| 86 | + store = MagicMock() |
| 87 | + |
| 88 | + # The remote file does not exist. Registration happens before DataFusion |
| 89 | + # tries to inspect it, which is the behavior under test. |
| 90 | + with ( |
| 91 | + patch.object(ctx, "register_object_store") as register, |
| 92 | + contextlib.suppress(Exception), |
| 93 | + ): |
| 94 | + getattr(ctx, method_name)(*args, path, object_store=store) |
| 95 | + |
| 96 | + register.assert_called_once_with("s3://", store, host="bucket") |
| 97 | + |
| 98 | + |
| 99 | +def test_register_csv_uses_first_path_for_object_store(ctx): |
| 100 | + store = MagicMock() |
| 101 | + paths = ["s3://bucket/a.csv", "s3://bucket/b.csv"] |
| 102 | + |
| 103 | + with ( |
| 104 | + patch.object(ctx, "register_object_store") as register, |
| 105 | + contextlib.suppress(Exception), |
| 106 | + ): |
| 107 | + ctx.register_csv("table", paths, object_store=store) |
| 108 | + |
| 109 | + register.assert_called_once_with("s3://", store, host="bucket") |
| 110 | + |
| 111 | + |
| 112 | +def test_object_store_none_does_not_register(ctx): |
| 113 | + with ( |
| 114 | + patch.object(ctx, "register_object_store") as register, |
| 115 | + contextlib.suppress(Exception), |
| 116 | + ): |
| 117 | + ctx.register_parquet("table", "missing.parquet") |
| 118 | + |
| 119 | + register.assert_not_called() |
| 120 | + |
| 121 | + |
| 122 | +def test_file_method_rejects_local_path_with_object_store(ctx): |
| 123 | + with pytest.raises(ValueError, match="Cannot determine object store URL"): |
| 124 | + ctx.register_parquet("table", "/local/file.parquet", object_store=MagicMock()) |
| 125 | + |
| 126 | + |
| 127 | +@pytest.mark.parametrize("method_name", ["register_parquet", "read_parquet"]) |
| 128 | +def test_parquet_methods_with_local_object_store(ctx, tmp_path, method_name): |
| 129 | + table = pa.table({"value": [10, 20, 30]}) |
| 130 | + parquet_path = tmp_path / "data.parquet" |
| 131 | + pq.write_table(table, parquet_path) |
| 132 | + |
| 133 | + path = parquet_path.as_uri() |
| 134 | + if method_name == "register_parquet": |
| 135 | + ctx.register_parquet("test_table", path, object_store=LocalFileSystem()) |
| 136 | + dataframe = ctx.sql("SELECT * FROM test_table") |
| 137 | + else: |
| 138 | + dataframe = ctx.read_parquet(path, object_store=LocalFileSystem()) |
| 139 | + |
| 140 | + assert dataframe.collect()[0].column("value").to_pylist() == [10, 20, 30] |
0 commit comments