1000 lines
37 KiB
Python
1000 lines
37 KiB
Python
import json
|
|
import os
|
|
import posixpath
|
|
from typing import Any, Callable, Dict, Iterator, List, Optional, Union
|
|
|
|
import pandas as pd
|
|
import pyarrow
|
|
import pyarrow as pa
|
|
import pytest
|
|
from packaging.version import parse as parse_version
|
|
from pyarrow.fs import FileType
|
|
from pytest_lazy_fixtures import lf as lazy_fixture
|
|
|
|
import ray
|
|
from ray.data._internal.utils.arrow_utils import get_pyarrow_version
|
|
from ray.data.block import Block
|
|
from ray.data.dataset import Dataset
|
|
from ray.data.datasource import FileBasedDatasource, PathPartitionParser
|
|
from ray.data.datasource.file_based_datasource import _resolve_paths_and_filesystem
|
|
from ray.data.datasource.partitioning import (
|
|
Partitioning,
|
|
PartitionStyle,
|
|
PathPartitionFilter,
|
|
)
|
|
from ray.data.expressions import col
|
|
from ray.data.tests.conftest import * # noqa
|
|
from ray.tests.conftest import * # noqa
|
|
|
|
|
|
class CSVDatasource(FileBasedDatasource):
|
|
def __init__(
|
|
self,
|
|
paths,
|
|
block_type: Union[pd.DataFrame, pa.Table],
|
|
**file_based_datasource_kwargs,
|
|
):
|
|
super().__init__(paths, **file_based_datasource_kwargs)
|
|
|
|
self._block_type = block_type
|
|
|
|
def _read_stream(self, f: pa.NativeFile, path: str) -> Iterator[Block]:
|
|
assert self._block_type in {pd.DataFrame, pa.Table}
|
|
|
|
if self._block_type is pa.Table:
|
|
from pyarrow import csv
|
|
|
|
yield csv.read_csv(f)
|
|
|
|
if self._block_type is pd.DataFrame:
|
|
yield pd.read_csv(f)
|
|
|
|
|
|
def write_csv(data: Dict[str, List[Any]], path: str) -> None:
|
|
df = pd.DataFrame(data)
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
df.to_csv(path, index=False, na_rep="NA")
|
|
|
|
|
|
def read_csv(
|
|
paths: Union[str, List[str]],
|
|
*,
|
|
partitioning: Partitioning,
|
|
block_type: Union[pd.DataFrame, pa.Table],
|
|
) -> Dataset:
|
|
datasource = CSVDatasource(paths, block_type=block_type, partitioning=partitioning)
|
|
return ray.data.read_datasource(datasource)
|
|
|
|
|
|
class PathPartitionEncoder:
|
|
"""Callable that generates directory path strings for path-based partition formats.
|
|
Path-based partition formats embed all partition keys and values directly in
|
|
their dataset file paths.
|
|
Two path partition formats are currently supported - `HIVE` and `DIRECTORY`.
|
|
For `HIVE` Partitioning, all partition directories will be generated using a
|
|
`{key1}={value1}/{key2}={value2}` naming convention under the base directory.
|
|
An accompanying ordered list of partition key field names must also be
|
|
provided, where the order and length of all partition values must match the
|
|
order and length of field names
|
|
For `DIRECTORY` Partitioning, all directories will be generated from partition
|
|
values using a `{value1}/{value2}` naming convention under the base directory.
|
|
"""
|
|
|
|
@staticmethod
|
|
def of(
|
|
style: PartitionStyle = PartitionStyle.HIVE,
|
|
base_dir: Optional[str] = None,
|
|
field_names: Optional[List[str]] = None,
|
|
filesystem: Optional["pyarrow.fs.FileSystem"] = None,
|
|
) -> "PathPartitionEncoder":
|
|
"""Creates a new partition path encoder.
|
|
Args:
|
|
style: The partition style - may be either HIVE or DIRECTORY.
|
|
base_dir: "/"-delimited base directory that all partition paths will be
|
|
generated under (exclusive).
|
|
field_names: The partition key field names (i.e. column names for tabular
|
|
datasets). Required for HIVE partition paths, optional for DIRECTORY
|
|
partition paths. When non-empty, the order and length of partition key
|
|
field names must match the order and length of partition values.
|
|
filesystem: Filesystem that will be used for partition path file I/O.
|
|
Returns:
|
|
The new partition path encoder.
|
|
"""
|
|
scheme = Partitioning(style, base_dir, field_names, None, filesystem)
|
|
return PathPartitionEncoder(scheme)
|
|
|
|
def __init__(self, partitioning: Partitioning):
|
|
"""Creates a new partition path encoder.
|
|
Args:
|
|
partitioning: The path-based partition scheme. All partition paths
|
|
will be generated under this scheme's base directory. Field names are
|
|
required for HIVE partition paths, optional for DIRECTORY partition
|
|
paths. When non-empty, the order and length of partition key field
|
|
names must match the order and length of partition values.
|
|
"""
|
|
style = partitioning.style
|
|
field_names = partitioning.field_names
|
|
if style == PartitionStyle.HIVE and not field_names:
|
|
raise ValueError(
|
|
"Hive partition path generation requires a corresponding list of "
|
|
"partition key field names. Please retry your request with one "
|
|
"or more field names specified."
|
|
)
|
|
generators = {
|
|
PartitionStyle.HIVE: self._as_hive_partition_dirs,
|
|
PartitionStyle.DIRECTORY: self._as_directory_partition_dirs,
|
|
}
|
|
self._encoder_fn: Callable[[List[str]], List[str]] = generators.get(style)
|
|
if self._encoder_fn is None:
|
|
raise ValueError(
|
|
f"Unsupported partition style: {style}. "
|
|
f"Supported styles: {generators.keys()}"
|
|
)
|
|
self._scheme = partitioning
|
|
|
|
def __call__(self, partition_values: List[str]) -> str:
|
|
"""Returns the partition directory path for the given partition value strings.
|
|
All files for this partition should be written to this directory. If a base
|
|
directory is set, then the partition directory path returned will be rooted in
|
|
this base directory.
|
|
Args:
|
|
partition_values: The partition value strings to include in the partition
|
|
path. For HIVE partition paths, the order and length of partition
|
|
values must match the order and length of partition key field names.
|
|
Returns:
|
|
Partition directory path for the given partition values.
|
|
"""
|
|
partition_dirs = self._as_partition_dirs(partition_values)
|
|
return posixpath.join(self._scheme.normalized_base_dir, *partition_dirs)
|
|
|
|
@property
|
|
def scheme(self) -> Partitioning:
|
|
"""Returns the partitioning for this encoder."""
|
|
return self._scheme
|
|
|
|
def _as_hive_partition_dirs(self, values: List[str]) -> List[str]:
|
|
"""Creates HIVE directory names for the given values."""
|
|
field_names = self._scheme.field_names
|
|
return [f"{field_names[i]}={val}" for i, val in enumerate(values)]
|
|
|
|
def _as_directory_partition_dirs(self, values: List[str]) -> List[str]:
|
|
"""Creates DIRECTORY partition directory names for the given values."""
|
|
return values
|
|
|
|
def _as_partition_dirs(self, values: List[str]) -> List[str]:
|
|
"""Creates a list of partition directory names for the given values."""
|
|
field_names = self._scheme.field_names
|
|
if field_names:
|
|
assert len(values) == len(field_names), (
|
|
f"Expected {len(field_names)} partition value(s) but found "
|
|
f"{len(values)}: {values}."
|
|
)
|
|
return self._encoder_fn(values)
|
|
|
|
|
|
@pytest.mark.parametrize("block_type", [pd.DataFrame, pa.Table])
|
|
class TestReadHivePartitionedFiles:
|
|
def test_read_single_file(self, tmp_path, block_type, ray_start_regular_shared):
|
|
path = os.path.join(tmp_path, "year=1970", "country=fr", "data.csv")
|
|
write_csv({"number": [1, 2, 3]}, path)
|
|
|
|
ds = read_csv(path, partitioning=Partitioning("hive"), block_type=block_type)
|
|
|
|
df = ds.to_pandas()
|
|
assert list(df.columns) == ["number", "year", "country"]
|
|
assert list(df["number"]) == [1, 2, 3]
|
|
assert list(df["year"]) == ["1970", "1970", "1970"]
|
|
assert list(df["country"]) == ["fr", "fr", "fr"]
|
|
|
|
def test_read_multiple_files(self, tmp_path, block_type, ray_start_regular_shared):
|
|
path1 = os.path.join(tmp_path, "year=1970", "country=fr", "data.csv")
|
|
write_csv({"number": [1, 2, 3]}, path1)
|
|
path2 = os.path.join(tmp_path, "year=1971", "country=ir", "data.csv")
|
|
write_csv({"number": [4, 5, 6]}, path2)
|
|
|
|
ds = read_csv(
|
|
[path1, path2], partitioning=Partitioning("hive"), block_type=block_type
|
|
)
|
|
|
|
df = ds.to_pandas()
|
|
assert list(df.columns) == ["number", "year", "country"]
|
|
assert list(df[df["year"] == "1970"]["number"]) == [1, 2, 3]
|
|
assert list(df[df["year"] == "1970"]["country"]) == ["fr", "fr", "fr"]
|
|
assert list(df[df["year"] == "1971"]["number"]) == [4, 5, 6]
|
|
assert list(df[df["year"] == "1971"]["country"]) == ["ir", "ir", "ir"]
|
|
|
|
@pytest.mark.parametrize(
|
|
"relative_paths",
|
|
[
|
|
["year=1970/country=fr/data.csv", "year=1971/language=ir/data.csv"],
|
|
["year=1970/country=fr/data.csv", "year=1971/ir/data.csv"],
|
|
["year=1970/country=fr/data.csv", "year=1971/data.csv"],
|
|
],
|
|
)
|
|
@pytest.mark.skip # TODO: Unskip this test once #28869 is fixed.
|
|
def test_read_files_with_mismatched_fields(
|
|
self, relative_paths, tmp_path, block_type, ray_start_regular_shared
|
|
):
|
|
paths = [
|
|
os.path.join(tmp_path, relative_path) for relative_path in relative_paths
|
|
]
|
|
for path in paths:
|
|
write_csv({"number": [0, 0, 0]}, path)
|
|
|
|
with pytest.raises(ValueError):
|
|
read_csv(paths, partitioning=Partitioning("hive"), block_type=block_type)
|
|
|
|
def test_read_files_with_conflicting_key(
|
|
self, tmp_path, block_type, ray_start_regular_shared
|
|
):
|
|
path = os.path.join(tmp_path, "month=01", "data.csv")
|
|
write_csv({"month": [1, 2, 3]}, path)
|
|
with pytest.raises(ValueError):
|
|
# `read_csv` should error because `month` is a field in both the CSV and
|
|
# the path, and the data is different.
|
|
ds = read_csv(
|
|
path, partitioning=Partitioning("hive"), block_type=block_type
|
|
)
|
|
ds.schema()
|
|
|
|
@pytest.mark.parametrize("data", [[1, 1, 1], [1, None, 1]])
|
|
def test_read_files_with_legally_conflicting_key(
|
|
self, data, tmp_path, block_type, ray_start_regular_shared
|
|
):
|
|
# `month` is a field in both the path and the CSV, but because the data is
|
|
# identical, we don't raise an error.
|
|
path = os.path.join(tmp_path, "month=01", "data.csv")
|
|
write_csv({"month": data}, path)
|
|
|
|
ds = read_csv(path, partitioning=Partitioning("hive"), block_type=block_type)
|
|
|
|
df = ds.to_pandas()
|
|
assert list(df.columns) == ["month"]
|
|
assert list(df["month"]) == [1, 1, 1]
|
|
|
|
|
|
@pytest.mark.parametrize("block_type", [pd.DataFrame, pa.Table])
|
|
class TestReadUnpartitionedFiles:
|
|
@pytest.mark.parametrize(
|
|
"relative_path", ["year=1970/country=fr/data.csv", "1970/fr/data.csv"]
|
|
)
|
|
def test_read_single_file(
|
|
self, relative_path, tmp_path, block_type, ray_start_regular_shared
|
|
):
|
|
path = os.path.join(tmp_path, relative_path)
|
|
write_csv({"number": [1, 2, 3]}, path)
|
|
|
|
ds = read_csv(path, partitioning=None, block_type=block_type)
|
|
|
|
# `read_csv` shouldn't include fields like `year` and `country`.`
|
|
assert list(ds.to_pandas().columns) == ["number"]
|
|
|
|
@pytest.mark.parametrize(
|
|
"relative_paths",
|
|
[
|
|
["year=1970/country=fr/data.csv", "year=1971/language=ir/data.csv"],
|
|
["year=1970/country=fr/data.csv", "year=1971/ir/data.csv"],
|
|
["year=1970/country=fr/data.csv", "year=1971/data.csv"],
|
|
["1970/fr/data.csv", "1971/data.csv"],
|
|
],
|
|
)
|
|
@pytest.mark.skip # TODO: Unskip this test once #28869 is fixed.
|
|
def test_read_files_with_mismatched_fields(
|
|
self, relative_paths, tmp_path, block_type, ray_start_regular_shared
|
|
):
|
|
paths = [
|
|
os.path.join(tmp_path, relative_path) for relative_path in relative_paths
|
|
]
|
|
for path in paths:
|
|
write_csv({"number": [0, 0, 0]})
|
|
|
|
# `read_csv` shouldn't raise an error if `partitioning` is set to `None`.
|
|
read_csv(paths, partitioning=None, block_type=block_type)
|
|
|
|
|
|
@pytest.mark.parametrize("block_type", [pd.DataFrame, pa.Table])
|
|
class TestReadDirPartitionedFiles:
|
|
def test_read_single_file(self, tmp_path, block_type, ray_start_regular_shared):
|
|
path = os.path.join(tmp_path, "1970", "fr", "data.csv")
|
|
write_csv({"number": [1, 2, 3]}, path)
|
|
|
|
ds = read_csv(
|
|
path,
|
|
partitioning=Partitioning(
|
|
"dir", field_names=["year", "country"], base_dir=tmp_path
|
|
),
|
|
block_type=block_type,
|
|
)
|
|
|
|
df = ds.to_pandas()
|
|
assert list(df.columns) == ["number", "year", "country"]
|
|
assert list(df["number"]) == [1, 2, 3]
|
|
assert list(df["year"]) == ["1970", "1970", "1970"]
|
|
assert list(df["country"]) == ["fr", "fr", "fr"]
|
|
|
|
def test_read_single_file_with_null_field(
|
|
self, tmp_path, block_type, ray_start_regular_shared
|
|
):
|
|
path = os.path.join(tmp_path, "1970", "data", "data.csv")
|
|
write_csv({"number": [1, 2, 3]}, path)
|
|
|
|
ds = read_csv(
|
|
path,
|
|
partitioning=Partitioning(
|
|
"dir", field_names=["year", None], base_dir=tmp_path
|
|
),
|
|
block_type=block_type,
|
|
)
|
|
|
|
df = ds.to_pandas()
|
|
assert list(df.columns) == ["number", "year"]
|
|
assert list(df["number"]) == [1, 2, 3]
|
|
assert list(df["year"]) == ["1970", "1970", "1970"]
|
|
|
|
def test_read_single_file_with_missing_field(
|
|
self, tmp_path, block_type, ray_start_regular_shared
|
|
):
|
|
path = os.path.join(tmp_path, "1970", "data.csv")
|
|
write_csv({"number": [0, 0, 0]}, path)
|
|
|
|
# `read_csv` should error because `path` is missing the `country` field.
|
|
with pytest.raises(ValueError):
|
|
read_csv(
|
|
path,
|
|
partitioning=Partitioning(
|
|
"dir", field_names=["year", "country"], base_dir=tmp_path
|
|
),
|
|
block_type=block_type,
|
|
).schema()
|
|
|
|
@pytest.mark.parametrize(
|
|
"relative_path", ["1970/data.csv", "1970/us/94704/data.csv"]
|
|
)
|
|
def test_read_single_file_with_invalid_field_names(
|
|
self, relative_path, tmp_path, block_type, ray_start_regular_shared
|
|
):
|
|
path = os.path.join(tmp_path, relative_path)
|
|
write_csv({"number": [0, 0, 0]}, path)
|
|
|
|
with pytest.raises(ValueError):
|
|
read_csv(
|
|
path,
|
|
partitioning=Partitioning(
|
|
"dir", field_names=["year", "country"], base_dir=tmp_path
|
|
),
|
|
block_type=block_type,
|
|
).schema()
|
|
|
|
def test_read_files_with_conflicting_key(
|
|
self, tmp_path, block_type, ray_start_regular_shared
|
|
):
|
|
path = os.path.join(tmp_path, "01", "data.csv")
|
|
write_csv({"month": [1, 2, 3]}, path)
|
|
with pytest.raises(ValueError):
|
|
# `read_csv` should error because `month` is a field in both the CSV and
|
|
# the path, and the data is different.
|
|
read_csv(
|
|
path,
|
|
partitioning=Partitioning(
|
|
"dir", field_names=["month"], base_dir=tmp_path
|
|
),
|
|
block_type=block_type,
|
|
).schema()
|
|
|
|
@pytest.mark.parametrize("data", [[1, 1, 1], [1, None, 1]])
|
|
def test_read_files_with_legally_conflicting_key(
|
|
self, data, tmp_path, block_type, ray_start_regular_shared
|
|
):
|
|
path = os.path.join(tmp_path, "01", "data.csv")
|
|
write_csv({"month": data}, path)
|
|
|
|
# `month` is a field in both the path and the CSV, but because the data is
|
|
# identical, we don't raise an error.
|
|
ds = read_csv(
|
|
path,
|
|
partitioning=Partitioning("dir", field_names=["month"], base_dir=tmp_path),
|
|
block_type=block_type,
|
|
)
|
|
|
|
df = ds.to_pandas()
|
|
assert list(df.columns) == ["month"]
|
|
assert list(df["month"]) == [1, 1, 1]
|
|
|
|
def test_read_multiple_files(self, tmp_path, block_type, ray_start_regular_shared):
|
|
path1 = os.path.join(tmp_path, "1970", "fr", "data.csv")
|
|
write_csv({"number": [1, 2, 3]}, path1)
|
|
path2 = os.path.join(tmp_path, "1971", "ir", "data.csv")
|
|
write_csv({"number": [4, 5, 6]}, path2)
|
|
|
|
ds = read_csv(
|
|
[path1, path2],
|
|
partitioning=Partitioning(
|
|
"dir", field_names=["year", "country"], base_dir=tmp_path
|
|
),
|
|
block_type=block_type,
|
|
)
|
|
|
|
df = ds.to_pandas()
|
|
assert list(df.columns) == ["number", "year", "country"]
|
|
assert list(df[df["year"] == "1970"]["number"]) == [1, 2, 3]
|
|
assert list(df[df["year"] == "1970"]["country"]) == ["fr", "fr", "fr"]
|
|
assert list(df[df["year"] == "1971"]["number"]) == [4, 5, 6]
|
|
assert list(df[df["year"] == "1971"]["country"]) == ["ir", "ir", "ir"]
|
|
|
|
|
|
def _verify_resolved_paths_and_filesystem(scheme: Partitioning):
|
|
assert scheme.base_dir is not None
|
|
assert scheme.normalized_base_dir is not None
|
|
paths, expected_fs = _resolve_paths_and_filesystem(
|
|
scheme.base_dir,
|
|
scheme.filesystem,
|
|
)
|
|
path = paths[0]
|
|
expected_path = f"{path}/" if path and not path.endswith("/") else path
|
|
assert scheme.normalized_base_dir == expected_path
|
|
assert isinstance(scheme.resolved_filesystem, type(expected_fs))
|
|
|
|
|
|
def test_partition_style_serde_round_trip():
|
|
for style in PartitionStyle:
|
|
serialized = json.dumps(style)
|
|
deserialized = PartitionStyle(json.loads(serialized))
|
|
assert deserialized == style
|
|
|
|
|
|
def test_path_partition_base_properties():
|
|
style = PartitionStyle.DIRECTORY
|
|
base_dir = "/foo/bar"
|
|
field_names = ["baz", "qux"]
|
|
scheme = Partitioning(style, base_dir, field_names, None)
|
|
assert scheme.style == style
|
|
assert scheme.base_dir == base_dir
|
|
assert scheme.field_names == field_names
|
|
_verify_resolved_paths_and_filesystem(scheme)
|
|
|
|
scheme = Partitioning(style, None, field_names, None)
|
|
assert scheme.style == style
|
|
assert scheme.base_dir == ""
|
|
assert scheme.field_names == field_names
|
|
_verify_resolved_paths_and_filesystem(scheme)
|
|
|
|
|
|
def test_path_partition_encoder_errors():
|
|
# no field names for default HIVE path partitioning
|
|
with pytest.raises(ValueError):
|
|
PathPartitionEncoder.of()
|
|
# explicit no field names for HIVE path partitioning
|
|
with pytest.raises(ValueError):
|
|
PathPartitionEncoder.of(style=PartitionStyle.HIVE, field_names=[])
|
|
# invalid path partitioning style
|
|
with pytest.raises(ValueError):
|
|
PathPartitionEncoder.of(style=None)
|
|
# partition field name and field value length mismatch
|
|
for style in [PartitionStyle.HIVE, PartitionStyle.DIRECTORY]:
|
|
path_partition_encoder = PathPartitionEncoder.of(
|
|
style,
|
|
field_names=["foo", "bar"],
|
|
)
|
|
with pytest.raises(TypeError):
|
|
path_partition_encoder(None)
|
|
with pytest.raises(AssertionError):
|
|
path_partition_encoder([])
|
|
with pytest.raises(AssertionError):
|
|
path_partition_encoder(["1"])
|
|
with pytest.raises(AssertionError):
|
|
path_partition_encoder(["1", "2", "3"])
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"fs,base_dir",
|
|
[
|
|
(None, None),
|
|
(lazy_fixture("local_fs"), lazy_fixture("local_path")),
|
|
(lazy_fixture("s3_fs"), lazy_fixture("s3_path")),
|
|
(
|
|
lazy_fixture("s3_fs_with_special_chars"),
|
|
lazy_fixture("s3_path_with_special_chars"),
|
|
),
|
|
],
|
|
)
|
|
def test_path_partition_encoder_hive(fs, base_dir):
|
|
field_names = ["foo", "bar"]
|
|
path_partition_encoder = PathPartitionEncoder.of(
|
|
field_names=field_names,
|
|
base_dir=base_dir,
|
|
filesystem=fs,
|
|
)
|
|
_verify_resolved_paths_and_filesystem(path_partition_encoder.scheme)
|
|
partition_values = ["1", "2"]
|
|
partition_path = path_partition_encoder(partition_values)
|
|
assert partition_path == posixpath.join(
|
|
path_partition_encoder.scheme.normalized_base_dir,
|
|
"foo=1",
|
|
"bar=2",
|
|
)
|
|
if fs is not None:
|
|
file_info = fs.get_file_info(partition_path)
|
|
assert file_info.type == FileType.NotFound
|
|
fs.create_dir(partition_path)
|
|
file_info = fs.get_file_info(partition_path)
|
|
assert file_info.type == FileType.Directory
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"fs,base_dir",
|
|
[
|
|
(None, None),
|
|
(lazy_fixture("local_fs"), lazy_fixture("local_path")),
|
|
(lazy_fixture("s3_fs"), lazy_fixture("s3_path")),
|
|
(
|
|
lazy_fixture("s3_fs_with_special_chars"),
|
|
lazy_fixture("s3_path_with_special_chars"),
|
|
),
|
|
],
|
|
)
|
|
def test_path_partition_encoder_directory(fs, base_dir):
|
|
path_partition_encoder = PathPartitionEncoder.of(
|
|
style=PartitionStyle.DIRECTORY,
|
|
field_names=["foo", "bar"],
|
|
base_dir=base_dir,
|
|
filesystem=fs,
|
|
)
|
|
_verify_resolved_paths_and_filesystem(path_partition_encoder.scheme)
|
|
partition_values = ["1", "2"]
|
|
partition_path = path_partition_encoder(partition_values)
|
|
assert partition_path == posixpath.join(
|
|
path_partition_encoder.scheme.normalized_base_dir,
|
|
*partition_values,
|
|
)
|
|
if fs is not None:
|
|
file_info = fs.get_file_info(partition_path)
|
|
assert file_info.type == FileType.NotFound
|
|
fs.create_dir(partition_path)
|
|
file_info = fs.get_file_info(partition_path)
|
|
assert file_info.type == FileType.Directory
|
|
path_partition_encoder = PathPartitionEncoder.of(
|
|
style=PartitionStyle.DIRECTORY,
|
|
base_dir=base_dir,
|
|
filesystem=fs,
|
|
)
|
|
partition_path = path_partition_encoder([])
|
|
assert partition_path == path_partition_encoder.scheme.normalized_base_dir
|
|
partition_path = path_partition_encoder(partition_values)
|
|
assert partition_path == posixpath.join(
|
|
path_partition_encoder.scheme.normalized_base_dir,
|
|
*partition_values,
|
|
)
|
|
|
|
|
|
def test_path_partition_parser_errors():
|
|
# no field names for DIRECTORY path partitioning
|
|
with pytest.raises(ValueError):
|
|
PathPartitionParser.of(style=PartitionStyle.DIRECTORY)
|
|
# explicit no field names for DIRECTORY path partitioning
|
|
with pytest.raises(ValueError):
|
|
PathPartitionParser.of(style=PartitionStyle.DIRECTORY, field_names=[])
|
|
# invalid path partitioning style
|
|
with pytest.raises(ValueError):
|
|
PathPartitionParser.of(style=None)
|
|
# HIVE partition field name and field value length or order mismatch
|
|
path_partition_parser = PathPartitionParser.of(
|
|
style=PartitionStyle.HIVE,
|
|
field_names=["foo", "bar"],
|
|
)
|
|
with pytest.raises(ValueError):
|
|
path_partition_parser("foo=1/")
|
|
with pytest.raises(ValueError):
|
|
path_partition_parser("bar=1/foo=2/")
|
|
with pytest.raises(ValueError):
|
|
path_partition_parser("foo=1/bar=2/qux=3/")
|
|
# ensure HIVE partition base directory is not considered a partition
|
|
path_partition_parser = PathPartitionParser.of(
|
|
style=PartitionStyle.HIVE,
|
|
base_dir="foo=1",
|
|
field_names=["foo", "bar"],
|
|
)
|
|
with pytest.raises(ValueError):
|
|
path_partition_parser("foo=1/bar=2/")
|
|
# DIRECTORY partition field name and field value length mismatch
|
|
path_partition_parser = PathPartitionParser.of(
|
|
style=PartitionStyle.DIRECTORY,
|
|
field_names=["foo", "bar"],
|
|
)
|
|
with pytest.raises(ValueError):
|
|
path_partition_parser("1/")
|
|
with pytest.raises(ValueError):
|
|
path_partition_parser("1/2/3/")
|
|
# ensure DIRECTORY partition base directory is not considered a partition
|
|
path_partition_parser = PathPartitionParser.of(
|
|
style=PartitionStyle.DIRECTORY,
|
|
base_dir="1",
|
|
field_names=["foo", "bar"],
|
|
)
|
|
with pytest.raises(ValueError):
|
|
path_partition_parser("1/2/")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"fs,base_dir",
|
|
[
|
|
(None, None),
|
|
(lazy_fixture("local_fs"), lazy_fixture("local_path")),
|
|
(lazy_fixture("s3_fs"), lazy_fixture("s3_path")),
|
|
(
|
|
lazy_fixture("s3_fs_with_special_chars"),
|
|
lazy_fixture("s3_path_with_special_chars"),
|
|
),
|
|
],
|
|
)
|
|
def test_path_partition_parser_hive(fs, base_dir):
|
|
partition_parser = PathPartitionParser.of(base_dir=base_dir, filesystem=fs)
|
|
_verify_resolved_paths_and_filesystem(partition_parser.scheme)
|
|
base_dir = partition_parser.scheme.normalized_base_dir
|
|
|
|
# parse unpartitioned paths...
|
|
partition_kvs = partition_parser("")
|
|
assert partition_kvs == {}
|
|
unpartitioned_paths = [
|
|
"",
|
|
"foo/1",
|
|
"bar/2",
|
|
"baz/3",
|
|
posixpath.join(base_dir, "test.txt"),
|
|
posixpath.join(base_dir, "foo/test.txt"),
|
|
posixpath.join(base_dir, "foo/bar/qux=3"),
|
|
posixpath.join(base_dir, "test=1.txt"),
|
|
]
|
|
for path in unpartitioned_paths:
|
|
assert partition_parser(path) == {}
|
|
|
|
partitioned_path = posixpath.join(base_dir, "foo=1/test.txt")
|
|
assert partition_parser(partitioned_path) == {"foo": "1"}
|
|
partitioned_path = posixpath.join(base_dir, " foo = 1 /test.txt")
|
|
assert partition_parser(partitioned_path) == {" foo ": " 1 "}
|
|
partitioned_path = posixpath.join(base_dir, "foo/bar=2/test.txt")
|
|
assert partition_parser(partitioned_path) == {"bar": "2"}
|
|
partitioned_path = posixpath.join(base_dir, "bar=2/foo=1/test")
|
|
assert partition_parser(partitioned_path) == {"foo": "1", "bar": "2"}
|
|
partitioned_path = posixpath.join(base_dir, "foo/bar/qux=3/")
|
|
assert partition_parser(partitioned_path) == {"qux": "3"}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"path, expected_partitions",
|
|
[
|
|
# '%2F' should decode to '/'
|
|
("bucket/key=partition%2Fvalue/file.txt", {"key": "partition/value"}),
|
|
# '+' must remain literal when decoding path components. See
|
|
# https://github.com/ray-project/ray/pull/57625#discussion_r2441360523.
|
|
("bucket/key=foo+bar/file.txt", {"key": "foo+bar"}),
|
|
# '%2B' should decode to '+'
|
|
("bucket/key=foo%2Bbar/file.txt", {"key": "foo+bar"}),
|
|
],
|
|
)
|
|
def test_path_partition_parser_decodes_special_characters(
|
|
path: str, expected_partitions: Dict[str, str]
|
|
):
|
|
partition_parser = PathPartitionParser.of(base_dir="bucket")
|
|
assert partition_parser(path) == expected_partitions
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"fs,base_dir",
|
|
[
|
|
(None, None),
|
|
(lazy_fixture("local_fs"), lazy_fixture("local_path")),
|
|
(lazy_fixture("s3_fs"), lazy_fixture("s3_path")),
|
|
(
|
|
lazy_fixture("s3_fs_with_special_chars"),
|
|
lazy_fixture("s3_path_with_special_chars"),
|
|
),
|
|
],
|
|
)
|
|
def test_path_partition_parser_dir(fs, base_dir):
|
|
partition_parser = PathPartitionParser.of(
|
|
PartitionStyle.DIRECTORY,
|
|
base_dir=base_dir,
|
|
field_names=["foo", "bar"],
|
|
filesystem=fs,
|
|
)
|
|
_verify_resolved_paths_and_filesystem(partition_parser.scheme)
|
|
base_dir = partition_parser.scheme.normalized_base_dir
|
|
|
|
# parse unpartitioned paths...
|
|
partition_kvs = partition_parser("")
|
|
assert partition_kvs == {}
|
|
if base_dir:
|
|
unpartitioned_paths = [
|
|
"",
|
|
"foo/1",
|
|
"bar/2",
|
|
"baz/3",
|
|
posixpath.join(base_dir, "test.txt"),
|
|
]
|
|
for path in unpartitioned_paths:
|
|
assert partition_parser(path) == {}
|
|
|
|
partitioned_path = posixpath.join(base_dir, "1/2/test.txt")
|
|
assert partition_parser(partitioned_path) == {"foo": "1", "bar": "2"}
|
|
partitioned_path = posixpath.join(base_dir, " 1 / t w o /test.txt")
|
|
assert partition_parser(partitioned_path) == {"foo": " 1 ", "bar": " t w o "}
|
|
partitioned_path = posixpath.join(base_dir, "2/1/test.txt")
|
|
assert partition_parser(partitioned_path) == {"foo": "2", "bar": "1"}
|
|
partitioned_path = posixpath.join(base_dir, "1/2/")
|
|
assert partition_parser(partitioned_path) == {"foo": "1", "bar": "2"}
|
|
partitioned_path = posixpath.join(base_dir, "1/2/3")
|
|
assert partition_parser(partitioned_path) == {"foo": "1", "bar": "2"}
|
|
|
|
partition_parser = PathPartitionParser.of(
|
|
PartitionStyle.DIRECTORY,
|
|
base_dir=base_dir,
|
|
field_names=["bar", "foo"],
|
|
filesystem=fs,
|
|
)
|
|
partitioned_path = posixpath.join(base_dir, "1/2/test")
|
|
assert partition_parser(partitioned_path) == {"bar": "1", "foo": "2"}
|
|
partitioned_path = posixpath.join(base_dir, "2/1/test")
|
|
assert partition_parser(partitioned_path) == {"bar": "2", "foo": "1"}
|
|
|
|
partition_parser = PathPartitionParser.of(
|
|
PartitionStyle.DIRECTORY,
|
|
base_dir=base_dir,
|
|
field_names=["year", None, "country"],
|
|
filesystem=fs,
|
|
)
|
|
|
|
partitioned_path = posixpath.join(base_dir, "1970/countries/fr/products.csv")
|
|
assert partition_parser(partitioned_path) == {"year": "1970", "country": "fr"}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"fs,base_dir",
|
|
[
|
|
(None, None),
|
|
(lazy_fixture("local_fs"), lazy_fixture("local_path")),
|
|
(lazy_fixture("s3_fs"), lazy_fixture("s3_path")),
|
|
(
|
|
lazy_fixture("s3_fs_with_special_chars"),
|
|
lazy_fixture("s3_path_with_special_chars"),
|
|
),
|
|
],
|
|
)
|
|
def test_path_partition_filter_hive(fs, base_dir):
|
|
pass_through = PathPartitionFilter.of(None, base_dir=base_dir, filesystem=fs)
|
|
_verify_resolved_paths_and_filesystem(pass_through.parser.scheme)
|
|
paths = pass_through([])
|
|
assert paths == []
|
|
paths = pass_through(["foo/1", "bar/2", "baz/3"])
|
|
assert paths == ["foo/1", "bar/2", "baz/3"]
|
|
|
|
filter_unpartitioned = PathPartitionFilter.of(
|
|
base_dir=base_dir,
|
|
filesystem=fs,
|
|
filter_fn=lambda d: bool(d),
|
|
)
|
|
_verify_resolved_paths_and_filesystem(filter_unpartitioned.parser.scheme)
|
|
base_dir = filter_unpartitioned.parser.scheme.normalized_base_dir
|
|
test_paths = [
|
|
posixpath.join(base_dir, "test.txt"),
|
|
posixpath.join(base_dir, "foo/test.txt"),
|
|
posixpath.join(base_dir, "foo=1/test.txt"),
|
|
posixpath.join(base_dir, "foo/bar=2/test.txt"),
|
|
posixpath.join(base_dir, "foo=1/bar=2/test"),
|
|
posixpath.join(base_dir, "foo/bar/qux=3/"),
|
|
posixpath.join(base_dir, "foo/bar/qux=3"),
|
|
posixpath.join(base_dir, "test=1.txt"),
|
|
]
|
|
if base_dir:
|
|
test_paths.extend(["test.txt", "foo=1/test.txt"])
|
|
paths = filter_unpartitioned(test_paths)
|
|
assert paths == [
|
|
posixpath.join(base_dir, "foo=1/test.txt"),
|
|
posixpath.join(base_dir, "foo/bar=2/test.txt"),
|
|
posixpath.join(base_dir, "foo=1/bar=2/test"),
|
|
posixpath.join(base_dir, "foo/bar/qux=3/"),
|
|
]
|
|
|
|
filter_values = PathPartitionFilter.of(
|
|
base_dir=base_dir,
|
|
filesystem=fs,
|
|
filter_fn=lambda d: d
|
|
and (d.get("qux") == "3" or (d.get("foo") == "1" and d.get("bar") == "2")),
|
|
)
|
|
_verify_resolved_paths_and_filesystem(filter_values.parser.scheme)
|
|
paths = filter_values(test_paths)
|
|
assert paths == [
|
|
posixpath.join(base_dir, "foo=1/bar=2/test"),
|
|
posixpath.join(base_dir, "foo/bar/qux=3/"),
|
|
]
|
|
|
|
filter_field_name_values = PathPartitionFilter.of(
|
|
base_dir=base_dir,
|
|
field_names=["foo", "bar"],
|
|
filesystem=fs,
|
|
filter_fn=lambda d: d and d.get("foo") == "1" and d.get("bar") == "2",
|
|
)
|
|
test_paths = [
|
|
posixpath.join(base_dir, "foo=1/bar=2/test"),
|
|
posixpath.join(base_dir, "prefix/foo=1/padding/bar=2/test"),
|
|
]
|
|
paths = filter_field_name_values(test_paths)
|
|
assert paths == test_paths
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"fs,base_dir",
|
|
[
|
|
(None, None),
|
|
(lazy_fixture("local_fs"), lazy_fixture("local_path")),
|
|
(lazy_fixture("s3_fs"), lazy_fixture("s3_path")),
|
|
(
|
|
lazy_fixture("s3_fs_with_special_chars"),
|
|
lazy_fixture("s3_path_with_special_chars"),
|
|
),
|
|
],
|
|
)
|
|
def test_path_partition_filter_directory(fs, base_dir):
|
|
pass_through = PathPartitionFilter.of(
|
|
None,
|
|
style=PartitionStyle.DIRECTORY,
|
|
base_dir=base_dir,
|
|
field_names=["foo", "bar"],
|
|
filesystem=fs,
|
|
)
|
|
paths = pass_through([])
|
|
assert paths == []
|
|
paths = pass_through(["foo/1", "bar/2", "baz/3"])
|
|
assert paths == ["foo/1", "bar/2", "baz/3"]
|
|
|
|
filter_unpartitioned = PathPartitionFilter.of(
|
|
style=PartitionStyle.DIRECTORY,
|
|
base_dir=base_dir,
|
|
field_names=["foo", "bar"],
|
|
filesystem=fs,
|
|
filter_fn=lambda d: bool(d),
|
|
)
|
|
_verify_resolved_paths_and_filesystem(filter_unpartitioned.parser.scheme)
|
|
base_dir = filter_unpartitioned.parser.scheme.normalized_base_dir
|
|
test_paths = [
|
|
posixpath.join(base_dir, "test.txt"),
|
|
posixpath.join(base_dir, "1/2/test.txt"),
|
|
posixpath.join(base_dir, "1/2/"),
|
|
posixpath.join(base_dir, "2/1/"),
|
|
posixpath.join(base_dir, "1/2/3"),
|
|
]
|
|
if base_dir:
|
|
# files outside of the base directory are implicitly unpartitioned
|
|
test_paths.extend(["test.txt", "1/2/test.txt"])
|
|
paths = filter_unpartitioned(test_paths)
|
|
assert paths == [
|
|
posixpath.join(base_dir, "1/2/test.txt"),
|
|
posixpath.join(base_dir, "1/2/"),
|
|
posixpath.join(base_dir, "2/1/"),
|
|
posixpath.join(base_dir, "1/2/3"),
|
|
]
|
|
|
|
filter_values = PathPartitionFilter.of(
|
|
style=PartitionStyle.DIRECTORY,
|
|
base_dir=base_dir,
|
|
field_names=["foo", "bar"],
|
|
filesystem=fs,
|
|
filter_fn=lambda d: d and d["foo"] == "1" and d["bar"] == "2",
|
|
)
|
|
_verify_resolved_paths_and_filesystem(filter_values.parser.scheme)
|
|
paths = filter_values(test_paths)
|
|
assert paths == [
|
|
posixpath.join(base_dir, "1/2/test.txt"),
|
|
posixpath.join(base_dir, "1/2/"),
|
|
posixpath.join(base_dir, "1/2/3"),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"partition_value,expected_type",
|
|
[
|
|
("1", int),
|
|
("1.0", float),
|
|
("spam", str),
|
|
("true", bool),
|
|
],
|
|
)
|
|
def test_field_types(partition_value, expected_type):
|
|
partitioning = Partitioning(style="hive", field_types={"key": expected_type})
|
|
parse = PathPartitionParser(partitioning)
|
|
|
|
partitions = parse(f"key={partition_value}/data.parquet")
|
|
|
|
assert set(partitions.keys()) == {"key"}
|
|
assert isinstance(partitions["key"], expected_type)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"path,predicate,expected_result,description",
|
|
[
|
|
# Simple equality matches
|
|
("country=US/file.parquet", col("country") == "US", True, "Exact match"),
|
|
("country=US/file.parquet", col("country") == "UK", False, "No match"),
|
|
# AND predicates
|
|
(
|
|
"country=US/year=2020/file.parquet",
|
|
(col("country") == "US") & (col("year") == "2020"),
|
|
True,
|
|
"AND both match",
|
|
),
|
|
(
|
|
"country=US/year=2020/file.parquet",
|
|
(col("country") == "US") & (col("year") == "2021"),
|
|
False,
|
|
"AND one doesn't match",
|
|
),
|
|
# OR predicates
|
|
(
|
|
"country=US/file.parquet",
|
|
(col("country") == "US") | (col("country") == "UK"),
|
|
True,
|
|
"OR first matches",
|
|
),
|
|
(
|
|
"country=FR/file.parquet",
|
|
(col("country") == "US") | (col("country") == "UK"),
|
|
False,
|
|
"OR neither matches",
|
|
),
|
|
# Comparison operators
|
|
("year=2020/file.parquet", col("year") > "2019", True, "Greater than"),
|
|
("year=2020/file.parquet", col("year") < "2019", False, "Less than"),
|
|
# NOT operator
|
|
("country=US/file.parquet", ~(col("country") == "UK"), True, "NOT false"),
|
|
("country=US/file.parquet", ~(col("country") == "US"), False, "NOT true"),
|
|
# IS_IN operator
|
|
(
|
|
"country=US/file.parquet",
|
|
col("country").is_in(["US", "UK"]),
|
|
True,
|
|
"IS_IN matches",
|
|
),
|
|
(
|
|
"country=FR/file.parquet",
|
|
col("country").is_in(["US", "UK"]),
|
|
False,
|
|
"IS_IN no match",
|
|
),
|
|
(
|
|
"year=2020/file.parquet",
|
|
col("year").is_in(["2019", "2020", "2021"]),
|
|
True,
|
|
"IS_IN with multiple values",
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.skipif(
|
|
get_pyarrow_version() < parse_version("14.0.0"),
|
|
reason="Partition predicate evaluation requires pyarrow >= 14.0.0",
|
|
)
|
|
def test_evaluate_predicate_on_partition(path, predicate, expected_result, description):
|
|
"""Test partition predicate evaluation for automatic partition pruning."""
|
|
parser = PathPartitionParser(Partitioning("hive"))
|
|
result = parser.evaluate_predicate_on_partition(path, predicate)
|
|
|
|
assert (
|
|
result == expected_result
|
|
), f"{description}: Expected {expected_result}, got {result}"
|
|
|
|
|
|
def test_evaluate_predicate_on_unpartitioned_file():
|
|
"""Test that unpartitioned files are conservatively included."""
|
|
parser = PathPartitionParser(Partitioning("hive"))
|
|
# Unpartitioned file should return False when filtering on partition columns
|
|
# (we can't determine if it matches the predicate without partition values)
|
|
result = parser.evaluate_predicate_on_partition(
|
|
"data.parquet", col("country") == "US"
|
|
)
|
|
|
|
assert result is False
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
sys.exit(pytest.main(["-v", __file__]))
|