fsspec-data 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/Cargo.lock +2 -2
  2. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/Cargo.toml +2 -2
  3. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/PKG-INFO +2 -2
  4. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/fsspec_data/__init__.py +1 -1
  5. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/fsspec_data/filesystem.py +30 -27
  6. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/fsspec_data/fsspec_data.abi3.so +0 -0
  7. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/fsspec_data/interchange.py +52 -15
  8. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/fsspec_data/tests/test_all.py +62 -0
  9. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/fsspec_data/tests/test_filesystem.py +40 -1
  10. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/pyproject.toml +4 -4
  11. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/rust/Cargo.toml +1 -1
  12. fsspec_data-0.2.3/rust/coverage +2229 -0
  13. fsspec_data-0.2.3/rust/python/lib.rs +327 -0
  14. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/rust/src/codec.rs +219 -7
  15. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/rust/src/error.rs +2 -0
  16. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/rust/src/lib.rs +2 -2
  17. fsspec_data-0.2.2/rust/coverage +0 -1855
  18. fsspec_data-0.2.2/rust/python/lib.rs +0 -168
  19. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/.gitignore +0 -0
  20. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/LICENSE +0 -0
  21. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/README.md +0 -0
  22. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/fsspec_data/_version.py +0 -0
  23. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/fsspec_data/backends/__init__.py +0 -0
  24. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/fsspec_data/backends/base.py +0 -0
  25. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/fsspec_data/backends/http.py +0 -0
  26. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/fsspec_data/backends/sqlalchemy.py +0 -0
  27. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/fsspec_data/tests/__init__.py +0 -0
  28. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/fsspec_data/tests/conftest.py +0 -0
  29. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/rust/.config/nextest.toml +0 -0
  30. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/rust/src/plan.rs +0 -0
  31. {fsspec_data-0.2.2 → fsspec_data-0.2.3}/rust/src/stream.rs +0 -0
@@ -464,7 +464,7 @@ dependencies = [
464
464
 
465
465
  [[package]]
466
466
  name = "fsspec_data"
467
- version = "0.2.2"
467
+ version = "0.2.3"
468
468
  dependencies = [
469
469
  "arrow",
470
470
  "bytes",
@@ -474,7 +474,7 @@ dependencies = [
474
474
 
475
475
  [[package]]
476
476
  name = "fsspec_data_py"
477
- version = "0.2.2"
477
+ version = "0.2.3"
478
478
  dependencies = [
479
479
  "arrow",
480
480
  "fsspec_data",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "fsspec_data_py"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  edition = "2021"
5
5
  publish = false
6
6
 
@@ -12,7 +12,7 @@ crate-type = ["cdylib"]
12
12
  [dependencies]
13
13
  fsspec_data = { path = "./rust", version = "*" }
14
14
  arrow = { version = "54.3.1", features = ["pyarrow"] }
15
- pyo3 = { version = "0.23", features = ["abi3-py311", "extension-module"] }
15
+ pyo3 = { version = "0.23", features = ["abi3-py310", "extension-module"] }
16
16
 
17
17
  [profile.release]
18
18
  panic = "abort"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fsspec-data
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Arrow interchange planning for the fsspec data ecosystem
5
5
  Project-URL: Repository, https://github.com/1kbgz/fsspec-data
6
6
  Project-URL: Homepage, https://github.com/1kbgz/fsspec-data
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.14
18
18
  Classifier: Programming Language :: Python :: Implementation :: CPython
19
19
  Classifier: Programming Language :: Python :: Implementation :: PyPy
20
20
  Classifier: Programming Language :: Rust
21
- Requires-Python: >=3.11
21
+ Requires-Python: >=3.10
22
22
  Requires-Dist: fsspec
23
23
  Requires-Dist: pyarrow
24
24
  Provides-Extra: develop
@@ -15,7 +15,7 @@ from .interchange import (
15
15
  plan_schema,
16
16
  )
17
17
 
18
- __version__ = "0.2.2"
18
+ __version__ = "0.2.3"
19
19
 
20
20
  __all__ = [
21
21
  "Codec",
@@ -70,11 +70,14 @@ class DataFileSystem(ChainedFileSystem):
70
70
  raise ValueError("fsspec-data is a read-only filesystem")
71
71
 
72
72
  path = self._strip_protocol(path)
73
- output = self._convert(path)
74
73
  file = tempfile.SpooledTemporaryFile(max_size=self.spool_max_size, mode="w+b")
75
- file.write(output)
76
- file.seek(0)
77
- self._sizes[path] = len(output)
74
+ try:
75
+ self._convert(path, file)
76
+ self._sizes[path] = file.tell()
77
+ file.seek(0)
78
+ except Exception:
79
+ file.close()
80
+ raise
78
81
  return file
79
82
 
80
83
  def info(self, path: str, **kwargs: Any) -> dict[str, Any]:
@@ -94,31 +97,31 @@ class DataFileSystem(ChainedFileSystem):
94
97
  entry = self.info(path, **kwargs)
95
98
  return [entry] if detail else [entry["name"]]
96
99
 
97
- def _convert(self, path: str) -> bytes:
100
+ def _convert(self, path: str, output) -> None:
98
101
  provided_format = self.provided_format or _format_from_path(self.fo)
99
102
  requested_format = self.requested_format or _format_from_path(path)
100
- source = self.fs.cat_file(self.fo)
101
- decoded = DEFAULT_REGISTRY.get(provided_format).decode_batches(
102
- source,
103
- schema=self.provided_schema,
104
- batch_size=self.batch_size,
105
- row_limit=self.row_limit,
106
- byte_limit=self.byte_limit,
107
- )
108
- if self.provided_schema is not None and not decoded.schema.equals(self.provided_schema, check_metadata=False):
109
- raise ValueError("provided schema does not match the source schema")
110
-
111
- provided_schema = self.provided_schema or decoded.schema
112
- requested_schema = self.requested_schema or provided_schema
113
- plan = InterchangeRequest(
114
- provided_format,
115
- requested_format,
116
- provided_schema,
117
- requested_schema,
118
- self.schema_policy,
119
- ).plan()
120
- batches = tuple(plan.apply_batch(batch) for batch in decoded.batches)
121
- return DEFAULT_REGISTRY.get(requested_format).encode_batches(batches, schema=requested_schema)
103
+ with self.fs.open(self.fo, "rb") as source:
104
+ decoded = DEFAULT_REGISTRY.get(provided_format).iter_batches(
105
+ source,
106
+ schema=self.provided_schema,
107
+ batch_size=self.batch_size,
108
+ row_limit=self.row_limit,
109
+ byte_limit=self.byte_limit,
110
+ )
111
+ if self.provided_schema is not None and not decoded.schema.equals(self.provided_schema, check_metadata=False):
112
+ raise ValueError("provided schema does not match the source schema")
113
+
114
+ provided_schema = self.provided_schema or decoded.schema
115
+ requested_schema = self.requested_schema or provided_schema
116
+ plan = InterchangeRequest(
117
+ provided_format,
118
+ requested_format,
119
+ provided_schema,
120
+ requested_schema,
121
+ self.schema_policy,
122
+ ).plan()
123
+ batches = (plan.apply_batch(batch) for batch in decoded)
124
+ DEFAULT_REGISTRY.get(requested_format).encode_batches_to(batches, output, schema=requested_schema)
122
125
 
123
126
 
124
127
  _SUFFIX_FORMATS = {
@@ -1,9 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Iterable
3
4
  from dataclasses import dataclass, replace
4
5
  from enum import Enum
5
6
  from importlib import import_module
6
- from typing import Any
7
+ from itertools import chain
8
+ from typing import Any, BinaryIO
7
9
 
8
10
  import pyarrow as pa
9
11
  import pyarrow.compute as pc
@@ -94,9 +96,34 @@ class Codec:
94
96
  raise TypeError("batches must contain pyarrow.RecordBatch objects")
95
97
  return _rust.encode_batches(self.format.value, schema, batches)
96
98
 
99
+ def encode_batches_to(
100
+ self,
101
+ batches: Iterable[pa.RecordBatch],
102
+ output: BinaryIO,
103
+ *,
104
+ schema: pa.Schema | None = None,
105
+ ) -> None:
106
+ batches = iter(batches)
107
+ if schema is None:
108
+ try:
109
+ first = next(batches)
110
+ except StopIteration as error:
111
+ raise ValueError("schema is required when encoding no record batches") from error
112
+ if not isinstance(first, pa.RecordBatch):
113
+ raise TypeError("batches must contain pyarrow.RecordBatch objects")
114
+ schema = first.schema
115
+ batches = chain((first,), batches)
116
+ schema = _ensure_pyarrow(schema)
117
+ writer = _rust.start_codec_writer(self.format.value, schema, output)
118
+ for batch in batches:
119
+ if not isinstance(batch, pa.RecordBatch):
120
+ raise TypeError("batches must contain pyarrow.RecordBatch objects")
121
+ writer.write_batch(batch)
122
+ writer.finish()
123
+
97
124
  def decode_batches(
98
125
  self,
99
- data: bytes | bytearray | memoryview,
126
+ data: bytes | bytearray | memoryview | BinaryIO,
100
127
  *,
101
128
  schema: pa.Schema | None = None,
102
129
  batch_size: int = 1024,
@@ -113,25 +140,35 @@ class Codec:
113
140
 
114
141
  def iter_batches(
115
142
  self,
116
- data: bytes | bytearray | memoryview,
143
+ data: bytes | bytearray | memoryview | BinaryIO,
117
144
  *,
118
145
  schema: pa.Schema | None = None,
119
146
  batch_size: int = 1024,
120
147
  row_limit: int | None = None,
121
148
  byte_limit: int | None = None,
122
149
  ) -> DecodedBatchStream:
123
- if not isinstance(data, (bytes, bytearray, memoryview)):
124
- raise TypeError("data must be bytes-like")
125
150
  if schema is not None:
126
151
  schema = _ensure_pyarrow(schema)
127
- decoded_schema, native = _rust.decode_stream(
128
- self.format.value,
129
- bytes(data),
130
- schema,
131
- batch_size,
132
- row_limit,
133
- byte_limit,
134
- )
152
+ if isinstance(data, (bytes, bytearray, memoryview)):
153
+ decoded_schema, native = _rust.decode_stream(
154
+ self.format.value,
155
+ bytes(data),
156
+ schema,
157
+ batch_size,
158
+ row_limit,
159
+ byte_limit,
160
+ )
161
+ elif hasattr(data, "read"):
162
+ decoded_schema, native = _rust.decode_reader(
163
+ self.format.value,
164
+ data,
165
+ schema,
166
+ batch_size,
167
+ row_limit,
168
+ byte_limit,
169
+ )
170
+ else:
171
+ raise TypeError("data must be bytes-like or a binary file-like object")
135
172
  return DecodedBatchStream(decoded_schema, native)
136
173
 
137
174
 
@@ -204,7 +241,7 @@ class InterchangePlan:
204
241
 
205
242
  def iter_batches(
206
243
  self,
207
- data: bytes | bytearray | memoryview,
244
+ data: bytes | bytearray | memoryview | BinaryIO,
208
245
  *,
209
246
  batch_size: int = 1024,
210
247
  row_limit: int | None = None,
@@ -222,7 +259,7 @@ class InterchangePlan:
222
259
 
223
260
  def convert(
224
261
  self,
225
- data: bytes | bytearray | memoryview,
262
+ data: bytes | bytearray | memoryview | BinaryIO,
226
263
  *,
227
264
  batch_size: int = 1024,
228
265
  row_limit: int | None = None,
@@ -1,3 +1,6 @@
1
+ import io
2
+ from typing import Any
3
+
1
4
  import pyarrow as pa
2
5
  import pyarrow.parquet as pq
3
6
  import pytest
@@ -280,6 +283,43 @@ def test_text_codec_round_trip_preserves_schema_and_rows(format, prefix):
280
283
  assert pa.Table.from_batches(decoded.batches).to_pylist() == pa.Table.from_batches(batches).to_pylist()
281
284
 
282
285
 
286
+ @pytest.mark.parametrize("format", list(DataFormat))
287
+ def test_codec_encodes_batches_to_binary_file(format):
288
+ provided, _ = schemas()
289
+ batches = (
290
+ pa.RecordBatch.from_arrays([pa.array([1, 2]), pa.array(["ada", None])], schema=provided),
291
+ pa.RecordBatch.from_arrays([pa.array([3]), pa.array(["margaret"])], schema=provided),
292
+ )
293
+ codec = DEFAULT_REGISTRY.get(format)
294
+ output = io.BytesIO()
295
+
296
+ codec.encode_batches_to(iter(batches), output)
297
+
298
+ decode_schema = provided if format in {DataFormat.CSV, DataFormat.JSONL} else None
299
+ decoded = codec.decode_batches(output.getvalue(), schema=decode_schema)
300
+ assert pa.Table.from_batches(decoded.batches).to_pylist() == pa.Table.from_batches(batches).to_pylist()
301
+
302
+
303
+ def test_codec_file_writer_requires_schema_for_empty_stream():
304
+ codec = DEFAULT_REGISTRY.get(DataFormat.ARROW)
305
+
306
+ with pytest.raises(ValueError, match="schema is required"):
307
+ codec.encode_batches_to((), io.BytesIO())
308
+
309
+
310
+ def test_codec_file_writer_rejects_non_batches():
311
+ provided, _ = schemas()
312
+ codec = DEFAULT_REGISTRY.get(DataFormat.ARROW)
313
+ invalid: Any = object()
314
+
315
+ with pytest.raises(TypeError, match="pyarrow.RecordBatch"):
316
+ codec.encode_batches_to(iter([invalid]), io.BytesIO())
317
+
318
+ batch = pa.record_batch([[1], ["ada"]], schema=provided)
319
+ with pytest.raises(TypeError, match="pyarrow.RecordBatch"):
320
+ codec.encode_batches_to(iter([batch, invalid]), io.BytesIO(), schema=provided)
321
+
322
+
283
323
  @pytest.mark.parametrize("format", [DataFormat.CSV, DataFormat.JSONL])
284
324
  def test_text_codec_decode_requires_schema(format):
285
325
  codec = DEFAULT_REGISTRY.get(format)
@@ -302,6 +342,28 @@ def test_lazy_decode_applies_batch_and_row_limits():
302
342
  assert pa.Table.from_batches(batches).column("id").to_pylist() == [1, 2, 3, 4]
303
343
 
304
344
 
345
+ def test_lazy_decode_reads_binary_file_without_eagerly_consuming_it():
346
+ class CountingReader(io.BytesIO):
347
+ def __init__(self, data):
348
+ super().__init__(data)
349
+ self.bytes_read = 0
350
+
351
+ def read(self, size=-1):
352
+ data = super().read(size)
353
+ self.bytes_read += len(data)
354
+ return data
355
+
356
+ schema = pa.schema([("id", pa.int64())])
357
+ batch = pa.record_batch([pa.array(range(1024))], schema=schema)
358
+ codec = DEFAULT_REGISTRY.get(DataFormat.ARROW)
359
+ encoded = codec.encode_batches((batch,) * 128)
360
+ source = CountingReader(encoded)
361
+ stream = codec.iter_batches(source)
362
+
363
+ assert next(stream).num_rows == 1024
364
+ assert source.bytes_read < len(encoded)
365
+
366
+
305
367
  def test_lazy_decode_observes_cancellation():
306
368
  schema = pa.schema([("id", pa.int64())])
307
369
  codec = DEFAULT_REGISTRY.get(DataFormat.ARROW)
@@ -1,9 +1,11 @@
1
+ import io
2
+
1
3
  import fsspec
2
4
  import pyarrow as pa
3
5
  import pyarrow.parquet as pq
4
6
  import pytest
5
7
 
6
- from fsspec_data import DataFileSystem
8
+ from fsspec_data import Codec, DataFileSystem, DecodedBatchStream
7
9
 
8
10
  SCHEMA = pa.schema([pa.field("id", pa.int64(), nullable=False), pa.field("name", pa.string())])
9
11
  SCHEMA_OPTIONS = {
@@ -34,6 +36,43 @@ def test_chained_filesystem_converts_csv_to_seekable_parquet(memory_fs):
34
36
  assert table.to_pylist() == [{"id": 1, "name": "ada"}, {"id": 2, "name": "grace"}]
35
37
 
36
38
 
39
+ def test_chained_filesystem_reads_source_without_cat_file(memory_fs, monkeypatch):
40
+ rows = [f"{index},name-{index}\n".encode() for index in range(50_000)]
41
+ encoded = b"id,name\n" + b"".join(rows)
42
+ bytes_read = 0
43
+
44
+ class CountingReader(io.BytesIO):
45
+ def read(self, size=-1):
46
+ nonlocal bytes_read
47
+ data = super().read(size)
48
+ bytes_read += len(data)
49
+ return data
50
+
51
+ monkeypatch.setattr(memory_fs, "open", lambda *args, **kwargs: CountingReader(encoded))
52
+ monkeypatch.setattr(memory_fs, "cat_file", lambda *args, **kwargs: pytest.fail("cat_file should not be called"))
53
+ fs = DataFileSystem(
54
+ fo="orders.csv",
55
+ fs=memory_fs,
56
+ provided_schema=SCHEMA_OPTIONS,
57
+ batch_size=1,
58
+ row_limit=1,
59
+ )
60
+
61
+ assert pq.read_table(fs.open("orders.parquet")).to_pylist() == [{"id": 0, "name": "name-0"}]
62
+ assert bytes_read < len(encoded)
63
+
64
+
65
+ def test_chained_filesystem_streams_batches_into_spooled_output(memory_fs, monkeypatch):
66
+ monkeypatch.setattr(DecodedBatchStream, "collect", lambda self: pytest.fail("decoded batches should not be collected"))
67
+ monkeypatch.setattr(Codec, "encode_batches", lambda self, *args, **kwargs: pytest.fail("encoded bytes should not be collected"))
68
+ fs = DataFileSystem(fo="orders.csv", fs=memory_fs, provided_schema=SCHEMA_OPTIONS)
69
+
70
+ assert pq.read_table(fs.open("orders.parquet")).to_pylist() == [
71
+ {"id": 1, "name": "ada"},
72
+ {"id": 2, "name": "grace"},
73
+ ]
74
+
75
+
37
76
  def test_fsspec_url_builds_chained_filesystem(memory_fs):
38
77
  fsspec.register_implementation("fsspec-data", DataFileSystem, clobber=True)
39
78
 
@@ -13,8 +13,8 @@ authors = [
13
13
  description = "Arrow interchange planning for the fsspec data ecosystem"
14
14
  readme = "README.md"
15
15
  license = { text = "Apache-2.0" }
16
- version = "0.2.2"
17
- requires-python = ">=3.11"
16
+ version = "0.2.3"
17
+ requires-python = ">=3.10"
18
18
  keywords = ["arrow", "data", "fsspec", "interchange"]
19
19
 
20
20
  classifiers = [
@@ -65,7 +65,7 @@ Repository = "https://github.com/1kbgz/fsspec-data"
65
65
  Homepage = "https://github.com/1kbgz/fsspec-data"
66
66
 
67
67
  [tool.bumpversion]
68
- current_version = "0.2.2"
68
+ current_version = "0.2.3"
69
69
  commit = true
70
70
  tag = false
71
71
  commit_args = "-s"
@@ -91,7 +91,7 @@ search = 'version = "{current_version}"'
91
91
  replace = 'version = "{new_version}"'
92
92
 
93
93
  [tool.cibuildwheel]
94
- build = "cp311-*"
94
+ build = "cp310-*"
95
95
  test-command = "pytest -vvv {project}/fsspec_data/tests"
96
96
  test-extras = "develop"
97
97
 
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "fsspec_data"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  authors = ["the fsspec-data authors <dev@1kbgz.com>"]
5
5
  edition = "2021"
6
6
  license = "Apache-2.0"