610 lines
24 KiB
Python
610 lines
24 KiB
Python
import logging
|
|
import posixpath
|
|
import urllib.parse
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
from typing import (
|
|
TYPE_CHECKING,
|
|
Any,
|
|
Callable,
|
|
Dict,
|
|
List,
|
|
Optional,
|
|
Type,
|
|
Union,
|
|
)
|
|
|
|
import numpy as np
|
|
|
|
from ray.util.annotations import DeveloperAPI, PublicAPI
|
|
|
|
if TYPE_CHECKING:
|
|
import pyarrow
|
|
|
|
from ray.data.expressions import Expr
|
|
|
|
|
|
PartitionDataType = Type[Union[int, float, str, bool]]
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@DeveloperAPI
|
|
class PartitionStyle(str, Enum):
|
|
"""Supported dataset partition styles.
|
|
|
|
Inherits from `str` to simplify plain text serialization/deserialization.
|
|
|
|
Examples:
|
|
>>> # Serialize to JSON text.
|
|
>>> json.dumps(PartitionStyle.HIVE) # doctest: +SKIP
|
|
'"hive"'
|
|
|
|
>>> # Deserialize from JSON text.
|
|
>>> PartitionStyle(json.loads('"hive"')) # doctest: +SKIP
|
|
<PartitionStyle.HIVE: 'hive'>
|
|
"""
|
|
|
|
HIVE = "hive"
|
|
DIRECTORY = "dir"
|
|
|
|
|
|
@DeveloperAPI
|
|
@dataclass
|
|
class Partitioning:
|
|
"""Partition scheme used to describe path-based partitions.
|
|
|
|
Path-based partition formats embed all partition keys and values directly in
|
|
their dataset file paths.
|
|
|
|
For example, to read a dataset with
|
|
`Hive-style partitions <https://athena.guide/articles/hive-style-partitioning>`_:
|
|
|
|
>>> import ray
|
|
>>> from ray.data.datasource.partitioning import Partitioning
|
|
>>> ds = ray.data.read_csv(
|
|
... "s3://anonymous@ray-example-data/iris.csv",
|
|
... partitioning=Partitioning("hive"),
|
|
... )
|
|
|
|
Instead, if your files are arranged in a directory structure such as:
|
|
|
|
.. code::
|
|
|
|
root/dog/dog_0.jpeg
|
|
root/dog/dog_1.jpeg
|
|
...
|
|
|
|
root/cat/cat_0.jpeg
|
|
root/cat/cat_1.jpeg
|
|
...
|
|
|
|
Then you can use directory-based partitioning:
|
|
|
|
>>> import ray
|
|
>>> from ray.data.datasource.partitioning import Partitioning
|
|
>>> root = "s3://anonymous@air-example-data/cifar-10/images"
|
|
>>> partitioning = Partitioning("dir", field_names=["class"], base_dir=root)
|
|
>>> ds = ray.data.read_images(root, partitioning=partitioning)
|
|
"""
|
|
|
|
#: The partition style - may be either HIVE or DIRECTORY.
|
|
style: PartitionStyle
|
|
#: "/"-delimited base directory that all partitioned paths should
|
|
#: exist under (exclusive). File paths either outside of, or at the first
|
|
#: level of, this directory will be considered unpartitioned. Specify
|
|
#: `None` or an empty string to search for partitions in all file path
|
|
#: directories.
|
|
base_dir: Optional[str] = None
|
|
#: The partition key field names (i.e. column names for tabular
|
|
#: datasets). When non-empty, the order and length of partition key
|
|
#: field names must match the order and length of partition values.
|
|
#: Required when parsing DIRECTORY partitioned paths or generating
|
|
#: HIVE partitioned paths.
|
|
field_names: Optional[List[str]] = None
|
|
#: A dictionary that maps partition key names to their desired data type. If not
|
|
#: provided, the data type defaults to string.
|
|
field_types: Optional[Dict[str, PartitionDataType]] = None
|
|
#: Filesystem that will be used for partition path file I/O.
|
|
filesystem: Optional["pyarrow.fs.FileSystem"] = None
|
|
|
|
def __post_init__(self):
|
|
if self.base_dir is None:
|
|
self.base_dir = ""
|
|
|
|
if self.field_types is None:
|
|
self.field_types = {}
|
|
|
|
self._normalized_base_dir = None
|
|
self._resolved_filesystem = None
|
|
|
|
@property
|
|
def normalized_base_dir(self) -> str:
|
|
"""Returns the base directory normalized for compatibility with a filesystem."""
|
|
if self._normalized_base_dir is None:
|
|
self._normalize_base_dir()
|
|
return self._normalized_base_dir
|
|
|
|
@property
|
|
def resolved_filesystem(self) -> "pyarrow.fs.FileSystem":
|
|
"""Returns the filesystem resolved for compatibility with a base directory."""
|
|
if self._resolved_filesystem is None:
|
|
self._normalize_base_dir()
|
|
return self._resolved_filesystem
|
|
|
|
def to_pyarrow(self) -> "pyarrow.dataset.Partitioning":
|
|
"""Convert to a PyArrow dataset Partitioning.
|
|
|
|
Returns:
|
|
Equivalent ``pyarrow.dataset.Partitioning`` instance.
|
|
|
|
Raises:
|
|
ValueError: If the partition style is not supported.
|
|
"""
|
|
import pyarrow.dataset as pds
|
|
|
|
schema = _partition_field_types_to_pa_schema(
|
|
self.field_names or [],
|
|
self.field_types or {},
|
|
)
|
|
|
|
if self.style == PartitionStyle.HIVE:
|
|
return pds.HivePartitioning(schema)
|
|
elif self.style == PartitionStyle.DIRECTORY:
|
|
return pds.DirectoryPartitioning(schema)
|
|
else:
|
|
raise ValueError(f"Unsupported partition style: {self.style}")
|
|
|
|
def _normalize_base_dir(self):
|
|
"""Normalizes the partition base directory for compatibility with the
|
|
given filesystem.
|
|
|
|
This should be called once a filesystem has been resolved to ensure that this
|
|
base directory is correctly discovered at the root of all partitioned file
|
|
paths.
|
|
"""
|
|
from ray.data.datasource.path_util import _resolve_paths_and_filesystem
|
|
|
|
paths, self._resolved_filesystem = _resolve_paths_and_filesystem(
|
|
self.base_dir,
|
|
self.filesystem,
|
|
)
|
|
assert (
|
|
len(paths) == 1
|
|
), f"Expected 1 normalized base directory, but found {len(paths)}"
|
|
normalized_base_dir = paths[0]
|
|
if len(normalized_base_dir) and not normalized_base_dir.endswith("/"):
|
|
normalized_base_dir += "/"
|
|
self._normalized_base_dir = normalized_base_dir
|
|
|
|
|
|
@DeveloperAPI
|
|
class PathPartitionParser:
|
|
"""Partition parser 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 under the base directory
|
|
will be discovered based on `{key1}={value1}/{key2}={value2}` naming
|
|
conventions. Key/value pairs do not need to be presented in the same
|
|
order across all paths. Directory names nested under the base directory that
|
|
don't follow this naming condition will be considered unpartitioned. If a
|
|
partition filter is defined, then it will be called with an empty input
|
|
dictionary for each unpartitioned file.
|
|
|
|
For `DIRECTORY` Partitioning, all directories under the base directory will
|
|
be interpreted as partition values of the form `{value1}/{value2}`. An
|
|
accompanying ordered list of partition field names must also be provided,
|
|
where the order and length of all partition values must match the order and
|
|
length of field names. Files stored directly in the base directory will
|
|
be considered unpartitioned. If a partition filter is defined, then it will
|
|
be called with an empty input dictionary for each unpartitioned file. For
|
|
example, if the base directory is `"foo"`, then `"foo.csv"` and `"foo/bar.csv"`
|
|
would be considered unpartitioned files but `"foo/bar/baz.csv"` would be associated
|
|
with partition `"bar"`. If the base directory is undefined, then `"foo.csv"` would
|
|
be unpartitioned, `"foo/bar.csv"` would be associated with partition `"foo"`, and
|
|
"foo/bar/baz.csv" would be associated with partition `("foo", "bar")`.
|
|
"""
|
|
|
|
@staticmethod
|
|
def of(
|
|
style: PartitionStyle = PartitionStyle.HIVE,
|
|
base_dir: Optional[str] = None,
|
|
field_names: Optional[List[str]] = None,
|
|
field_types: Optional[Dict[str, PartitionDataType]] = None,
|
|
filesystem: Optional["pyarrow.fs.FileSystem"] = None,
|
|
) -> "PathPartitionParser":
|
|
"""Creates a path-based partition parser using a flattened argument list.
|
|
|
|
Args:
|
|
style: The partition style - may be either HIVE or DIRECTORY.
|
|
base_dir: "/"-delimited base directory to start searching for partitions
|
|
(exclusive). File paths outside of this directory will be considered
|
|
unpartitioned. Specify `None` or an empty string to search for
|
|
partitions in all file path directories.
|
|
field_names: The partition key names. Required for DIRECTORY partitioning.
|
|
Optional for HIVE partitioning. When non-empty, the order and length of
|
|
partition key field names must match the order and length of partition
|
|
directories discovered. Partition key field names are not required to
|
|
exist in the dataset schema.
|
|
field_types: A dictionary that maps partition key names to their desired
|
|
data type. If not provided, the data type default to string.
|
|
filesystem: Filesystem that will be used for partition path file I/O.
|
|
|
|
Returns:
|
|
The new path-based partition parser.
|
|
"""
|
|
scheme = Partitioning(style, base_dir, field_names, field_types, filesystem)
|
|
return PathPartitionParser(scheme)
|
|
|
|
def __init__(self, partitioning: Partitioning):
|
|
"""Creates a path-based partition parser.
|
|
|
|
Args:
|
|
partitioning: The path-based partition scheme. The parser starts
|
|
searching for partitions from this scheme's base directory. File paths
|
|
outside the base directory will be considered unpartitioned. If the
|
|
base directory is `None` or an empty string then this will search for
|
|
partitions in all file path directories. Field names are required for
|
|
DIRECTORY partitioning, and optional for HIVE partitioning. When
|
|
non-empty, the order and length of partition key field names must match
|
|
the order and length of partition directories discovered.
|
|
"""
|
|
style = partitioning.style
|
|
field_names = partitioning.field_names
|
|
if style == PartitionStyle.DIRECTORY and not field_names:
|
|
raise ValueError(
|
|
"Directory partitioning requires a corresponding list of "
|
|
"partition key field names. Please retry your request with one "
|
|
"or more field names specified."
|
|
)
|
|
parsers = {
|
|
PartitionStyle.HIVE: self._parse_hive_path,
|
|
PartitionStyle.DIRECTORY: self._parse_dir_path,
|
|
}
|
|
self._parser_fn: Callable[[str], Dict[str, str]] = parsers.get(style)
|
|
if self._parser_fn is None:
|
|
raise ValueError(
|
|
f"Unsupported partition style: {style}. "
|
|
f"Supported styles: {parsers.keys()}"
|
|
)
|
|
self._scheme = partitioning
|
|
|
|
def __call__(self, path: str) -> Dict[str, str]:
|
|
"""Parses partition keys and values from a single file path.
|
|
|
|
Args:
|
|
path: Input file path to parse.
|
|
|
|
Returns:
|
|
Dictionary mapping directory partition keys to values from the input file
|
|
path. Returns an empty dictionary for unpartitioned files.
|
|
"""
|
|
dir_path = self._dir_path_trim_base(path)
|
|
if dir_path is None:
|
|
return {}
|
|
partitions: Dict[str, str] = self._parser_fn(dir_path)
|
|
|
|
for field, data_type in self._scheme.field_types.items():
|
|
partitions[field] = _cast_value(partitions[field], data_type)
|
|
|
|
return partitions
|
|
|
|
def evaluate_predicate_on_partition(self, path: str, predicate: "Expr") -> bool:
|
|
"""Evaluate a predicate expression against partition values from a path.
|
|
|
|
This method enables partition pruning by evaluating predicates that reference
|
|
partition columns against the partition values parsed from file paths.
|
|
|
|
Args:
|
|
path: File path to parse partition values from.
|
|
predicate: Expression that references partition columns.
|
|
|
|
Returns:
|
|
True if the partition satisfies the predicate (should read the file),
|
|
False if it doesn't (can skip the file for partition pruning).
|
|
"""
|
|
import pyarrow as pa
|
|
|
|
from ray.data._internal.planner.plan_expression.expression_evaluator import (
|
|
NativeExpressionEvaluator,
|
|
)
|
|
|
|
# Parse partition values from the file path
|
|
partition_values = self(path)
|
|
|
|
if not partition_values:
|
|
# Unpartitioned file - exclude it when filtering on partition columns
|
|
# If the predicate references partition columns and the file doesn't have
|
|
# partition values in its path, we can't determine if it matches
|
|
return False
|
|
|
|
try:
|
|
# Create a single-row table with partition values
|
|
partition_table = pa.table(
|
|
{col: [val] for col, val in partition_values.items()}
|
|
)
|
|
|
|
# Evaluate using Ray Data's native evaluator
|
|
evaluator = NativeExpressionEvaluator(partition_table)
|
|
result = evaluator.visit(predicate)
|
|
|
|
# Extract boolean result from array-like types.
|
|
# NOTE: We must use ``.as_py()`` for PyArrow scalars because
|
|
# ``bool(pa.BooleanScalar(False))`` returns ``True`` (it
|
|
# checks validity/not-null, not the boolean value).
|
|
if isinstance(result, (pa.Array, pa.ChunkedArray)):
|
|
assert (
|
|
len(result) == 1
|
|
), f"Result expected to be of length 1 (got {result})"
|
|
return bool(result[0].as_py())
|
|
|
|
if isinstance(result, np.ndarray):
|
|
assert (
|
|
len(result) == 1
|
|
), f"Result expected to be of length 1 (got {result})"
|
|
return bool(result[0])
|
|
|
|
# Import pandas here to avoid circular dependencies
|
|
import pandas as pd
|
|
|
|
if isinstance(result, pd.Series):
|
|
assert (
|
|
len(result) == 1
|
|
), f"Result expected to be of length 1 (got {result})"
|
|
return bool(result.iloc[0])
|
|
|
|
# Scalar result
|
|
if isinstance(result, pa.Scalar):
|
|
return bool(result.as_py())
|
|
|
|
return bool(result)
|
|
except Exception:
|
|
logger.debug(
|
|
"Failed to evaluate predicate on partition for path %s, "
|
|
"conservatively including file.",
|
|
path,
|
|
exc_info=True,
|
|
)
|
|
return True
|
|
|
|
@property
|
|
def scheme(self) -> Partitioning:
|
|
"""Returns the partitioning for this parser."""
|
|
return self._scheme
|
|
|
|
def _dir_path_trim_base(self, path: str) -> Optional[str]:
|
|
"""Trims the normalized base directory and returns the directory path.
|
|
|
|
Returns None if the path does not start with the normalized base directory.
|
|
Simply returns the directory path if the base directory is undefined.
|
|
"""
|
|
if not path.startswith(self._scheme.normalized_base_dir):
|
|
return None
|
|
path = path[len(self._scheme.normalized_base_dir) :]
|
|
return posixpath.dirname(path)
|
|
|
|
def _parse_hive_path(self, dir_path: str) -> Dict[str, str]:
|
|
"""Hive partition path parser.
|
|
|
|
Returns a dictionary mapping partition keys to values given a hive-style
|
|
partition path of the form "{key1}={value1}/{key2}={value2}/..." or an empty
|
|
dictionary for unpartitioned files.
|
|
"""
|
|
dirs = [d for d in dir_path.split("/") if d and (d.count("=") == 1)]
|
|
kv_pairs = [d.split("=") for d in dirs] if dirs else []
|
|
# NOTE: PyArrow URL-encodes partition values when writing to cloud storage. To
|
|
# ensure the values are consistent when you read them back, we need to
|
|
# URL-decode them. See https://github.com/apache/arrow/issues/34905.
|
|
kv_pairs = [[key, urllib.parse.unquote(value)] for key, value in kv_pairs]
|
|
|
|
field_names = self._scheme.field_names
|
|
if field_names and kv_pairs:
|
|
if len(kv_pairs) != len(field_names):
|
|
raise ValueError(
|
|
f"Expected {len(field_names)} partition value(s) but found "
|
|
f"{len(kv_pairs)}: {kv_pairs}."
|
|
)
|
|
for i, field_name in enumerate(field_names):
|
|
if kv_pairs[i][0] != field_name:
|
|
raise ValueError(
|
|
f"Expected partition key {field_name} but found "
|
|
f"{kv_pairs[i][0]}"
|
|
)
|
|
return dict(kv_pairs)
|
|
|
|
def _parse_dir_path(self, dir_path: str) -> Dict[str, str]:
|
|
"""Directory partition path parser.
|
|
|
|
Returns a dictionary mapping directory partition keys to values from a
|
|
partition path of the form "{value1}/{value2}/..." or an empty dictionary for
|
|
unpartitioned files.
|
|
|
|
Requires a corresponding ordered list of partition key field names to map the
|
|
correct key to each value.
|
|
"""
|
|
dirs = [d for d in dir_path.split("/") if d]
|
|
field_names = self._scheme.field_names
|
|
|
|
if dirs and len(dirs) != len(field_names):
|
|
raise ValueError(
|
|
f"Expected {len(field_names)} partition value(s) but found "
|
|
f"{len(dirs)}: {dirs}."
|
|
)
|
|
|
|
if not dirs:
|
|
return {}
|
|
return {
|
|
field: directory
|
|
for field, directory in zip(field_names, dirs)
|
|
if field is not None
|
|
}
|
|
|
|
|
|
@PublicAPI(stability="beta")
|
|
class PathPartitionFilter:
|
|
"""Partition filter for path-based partition formats.
|
|
|
|
Used to explicitly keep or reject files based on a custom filter function that
|
|
takes partition keys and values parsed from the file's path as input.
|
|
"""
|
|
|
|
@staticmethod
|
|
def of(
|
|
filter_fn: Callable[[Dict[str, str]], bool],
|
|
style: PartitionStyle = PartitionStyle.HIVE,
|
|
base_dir: Optional[str] = None,
|
|
field_names: Optional[List[str]] = None,
|
|
field_types: Optional[Dict[str, PartitionDataType]] = None,
|
|
filesystem: Optional["pyarrow.fs.FileSystem"] = None,
|
|
) -> "PathPartitionFilter":
|
|
"""Creates a path-based partition filter using a flattened argument list.
|
|
|
|
Args:
|
|
filter_fn: Callback used to filter partitions. Takes a dictionary mapping
|
|
partition keys to values as input. Unpartitioned files are denoted with
|
|
an empty input dictionary. Returns `True` to read a file for that
|
|
partition or `False` to skip it. Partition keys and values are always
|
|
strings read from the filesystem path. For example, this removes all
|
|
unpartitioned files:
|
|
|
|
.. code:: python
|
|
|
|
lambda d: True if d else False
|
|
|
|
This raises an assertion error for any unpartitioned file found:
|
|
|
|
.. code:: python
|
|
|
|
def do_assert(val, msg):
|
|
assert val, msg
|
|
|
|
lambda d: do_assert(d, "Expected all files to be partitioned!")
|
|
|
|
And this only reads files from January, 2022 partitions:
|
|
|
|
.. code:: python
|
|
|
|
lambda d: d["month"] == "January" and d["year"] == "2022"
|
|
|
|
style: The partition style - may be either HIVE or DIRECTORY.
|
|
base_dir: "/"-delimited base directory to start searching for partitions
|
|
(exclusive). File paths outside of this directory will be considered
|
|
unpartitioned. Specify `None` or an empty string to search for
|
|
partitions in all file path directories.
|
|
field_names: The partition key names. Required for DIRECTORY partitioning.
|
|
Optional for HIVE partitioning. When non-empty, the order and length of
|
|
partition key field names must match the order and length of partition
|
|
directories discovered. Partition key field names are not required to
|
|
exist in the dataset schema.
|
|
field_types: A dictionary that maps partition key names to their desired
|
|
data type. If not provided, the data type defaults to string.
|
|
filesystem: Filesystem that will be used for partition path file I/O.
|
|
|
|
Returns:
|
|
The new path-based partition filter.
|
|
"""
|
|
scheme = Partitioning(style, base_dir, field_names, field_types, filesystem)
|
|
path_partition_parser = PathPartitionParser(scheme)
|
|
return PathPartitionFilter(path_partition_parser, filter_fn)
|
|
|
|
def __init__(
|
|
self,
|
|
path_partition_parser: PathPartitionParser,
|
|
filter_fn: Callable[[Dict[str, str]], bool],
|
|
):
|
|
"""Creates a new path-based partition filter based on a parser.
|
|
|
|
Args:
|
|
path_partition_parser: The path-based partition parser.
|
|
filter_fn: Callback used to filter partitions. Takes a dictionary mapping
|
|
partition keys to values as input. Unpartitioned files are denoted with
|
|
an empty input dictionary. Returns `True` to read a file for that
|
|
partition or `False` to skip it. Partition keys and values are always
|
|
strings read from the filesystem path. For example, this removes all
|
|
unpartitioned files:
|
|
``lambda d: True if d else False``
|
|
This raises an assertion error for any unpartitioned file found:
|
|
``lambda d: assert d, "Expected all files to be partitioned!"``
|
|
And this only reads files from January, 2022 partitions:
|
|
``lambda d: d["month"] == "January" and d["year"] == "2022"``
|
|
"""
|
|
self._parser = path_partition_parser
|
|
self._filter_fn = filter_fn
|
|
|
|
def __call__(self, paths: List[str]) -> List[str]:
|
|
"""Returns all paths that pass this partition scheme's partition filter.
|
|
|
|
If no partition filter is set, then returns all input paths. If a base
|
|
directory is set, then only paths under this base directory will be parsed
|
|
for partitions. All paths outside of this base directory will automatically
|
|
be considered unpartitioned, and passed into the filter function as empty
|
|
dictionaries.
|
|
|
|
Also normalizes the partition base directory for compatibility with the
|
|
given filesystem before applying the filter.
|
|
|
|
Args:
|
|
paths: Paths to pass through the partition filter function. All
|
|
paths should be normalized for compatibility with the given
|
|
filesystem.
|
|
Returns:
|
|
List of paths that pass the partition filter, or all paths if no
|
|
partition filter is defined.
|
|
"""
|
|
filtered_paths = paths
|
|
if self._filter_fn is not None:
|
|
filtered_paths = [path for path in paths if self.apply(path)]
|
|
return filtered_paths
|
|
|
|
def apply(self, path: str) -> bool:
|
|
return self._filter_fn(self._parser(path))
|
|
|
|
@property
|
|
def parser(self) -> PathPartitionParser:
|
|
"""Returns the path partition parser for this filter."""
|
|
return self._parser
|
|
|
|
|
|
def _partition_field_types_to_pa_schema(
|
|
field_names: List[str],
|
|
field_types: Dict[str, PartitionDataType],
|
|
) -> "pyarrow.Schema":
|
|
"""Build a PyArrow schema from partition field names and Python types.
|
|
|
|
Args:
|
|
field_names: Ordered partition key names.
|
|
field_types: Mapping from field name to Python type. Fields not
|
|
present in the map default to ``str`` (``pa.string()``).
|
|
|
|
Returns:
|
|
A ``pyarrow.Schema`` with one field per partition key.
|
|
"""
|
|
import pyarrow as pa
|
|
|
|
type_map = {
|
|
int: pa.int64(),
|
|
float: pa.float64(),
|
|
bool: pa.bool_(),
|
|
str: pa.string(),
|
|
}
|
|
fields = []
|
|
for name in field_names:
|
|
py_type: PartitionDataType = field_types.get(name, str)
|
|
pa_type = type_map.get(py_type, pa.string())
|
|
fields.append(pa.field(name, pa_type))
|
|
return pa.schema(fields)
|
|
|
|
|
|
def _cast_value(value: str, data_type: PartitionDataType) -> Any:
|
|
if data_type is int:
|
|
return int(value)
|
|
elif data_type is float:
|
|
return float(value)
|
|
elif data_type is bool:
|
|
return value.lower() == "true"
|
|
else:
|
|
return value
|