Files
ray-project--ray/python/ray/data/_internal/datasource/webdataset_datasource.py
T
2026-07-13 13:17:40 +08:00

443 lines
14 KiB
Python

# Copyright NVIDIA Corporation 2023
# SPDX-License-Identifier: Apache-2.0
import fnmatch
import io
import json
import re
import tarfile
from functools import partial
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Union
from ray._common.utils import env_bool
from ray.data._internal.util import iterate_with_retry
from ray.data.block import Block, BlockAccessor
from ray.data.datasource.file_based_datasource import FileBasedDatasource
ALLOW_UNSAFE_DESERIALIZATION_ENV_VAR = (
"RAY_DATA_WEBDATASET_ALLOW_UNSAFE_DESERIALIZATION"
)
if TYPE_CHECKING:
import pyarrow
def _base_plus_ext(path: str):
"""Split off all file extensions.
Returns base, allext.
Args:
path: path with extensions
Returns:
str: path with all extensions removed
"""
match = re.match(r"^((?:.*/|)[^.]+)[.]([^/]*)$", path)
if not match:
return None, None
return match.group(1), match.group(2)
def _valid_sample(sample: Dict[str, Any]):
"""Check whether a sample is valid.
Args:
sample: sample to be checked
Returns:
``True`` if the sample is a non-empty dict without the ``__bad__`` flag.
"""
return (
sample is not None
and isinstance(sample, dict)
and len(list(sample.keys())) > 0
and not sample.get("__bad__", False)
)
def _apply_list(
f: Union[Callable, List[Callable]], sample: Dict[str, Any], default: Callable = None
):
"""Apply a list of functions to a sample.
Args:
f: function or list of functions
sample: sample to be modified
default: default function to be applied to all keys.
Defaults to None.
Returns:
modified sample
"""
if f is None:
return sample
if not isinstance(f, list):
f = [f]
for g in f:
if default is not None and not callable(g):
g = partial(default, format=g)
sample = g(sample)
return sample
def _check_suffix(suffix: str, suffixes: Union[list, callable]):
"""Check whether a suffix is valid.
Suffixes can be either None (=accept everything), a callable,
or a list of patterns. If the pattern contains */? it is treated
as a glob pattern, otherwise it is treated as a literal.
Args:
suffix: suffix to be checked
suffixes: list of valid suffixes
Returns:
``True`` if the suffix matches the allowed patterns.
"""
if suffixes is None:
return True
if callable(suffixes):
return suffixes(suffix)
for pattern in suffixes:
if "*" in pattern or "?" in pattern:
if fnmatch.fnmatch("." + suffix, pattern):
return True
elif suffix == pattern or "." + suffix == pattern:
return True
return False
def _tar_file_iterator(
fileobj: Any,
fileselect: Optional[Union[bool, callable, list]] = None,
filerename: Optional[Union[bool, callable, list]] = None,
verbose_open: bool = False,
meta: dict = None,
) -> Iterator[Dict[str, Any]]:
"""Iterate over tar file, yielding filename, content pairs for the given tar stream.
Args:
fileobj: file object
fileselect: patterns or function selecting
files to be selected
filerename: patterns or function used to rename selected files
before yielding them.
verbose_open: if ``True``, print progress messages when starting
and finishing iteration over the tar stream.
meta: metadata to be added to each sample
Yields:
Dict[str, Any]: Dictionaries with ``fname`` and ``data`` keys for each
selected file in the tar stream.
"""
meta = meta or {}
stream = tarfile.open(fileobj=fileobj, mode="r|*")
if verbose_open:
print(f"start {meta}")
for tarinfo in stream:
fname = tarinfo.name
if not tarinfo.isreg() or fname is None:
continue
data = stream.extractfile(tarinfo).read()
fname = _apply_list(filerename, fname)
assert isinstance(fname, str)
if not _check_suffix(fname, fileselect):
continue
result = dict(fname=fname, data=data)
yield result
if verbose_open:
print(f"done {meta}")
def _group_by_keys(
data: List[Dict[str, Any]],
keys: callable = _base_plus_ext,
suffixes: Optional[Union[list, callable]] = None,
meta: dict = None,
) -> Iterator[Dict[str, Any]]:
"""Return function over iterator that groups key, value pairs into samples.
Args:
data: iterator over key, value pairs
keys: function that returns key, suffix for a given key
suffixes: list of suffixes to be included in the sample
meta: metadata to be added to each sample
Yields:
Dict[str, Any]: Grouped samples, where files sharing the same key prefix are
combined into a single dictionary.
"""
meta = meta or {}
current_sample = None
for filesample in data:
assert isinstance(filesample, dict)
fname, value = filesample["fname"], filesample["data"]
prefix, suffix = keys(fname)
if prefix is None:
continue
if current_sample is None or prefix != current_sample["__key__"]:
if _valid_sample(current_sample):
current_sample.update(meta)
yield current_sample
current_sample = dict(__key__=prefix)
if "__url__" in filesample:
current_sample["__url__"] = filesample["__url__"]
if suffix in current_sample:
raise ValueError(
f"{fname}: duplicate file name in tar file "
+ f"{suffix} {current_sample.keys()}, tar is {meta['__url__']}"
)
if suffixes is None or _check_suffix(suffix, suffixes):
current_sample[suffix] = value
if _valid_sample(current_sample):
current_sample.update(meta)
yield current_sample
def _default_decoder(
sample: Dict[str, Any],
format: Optional[Union[bool, str]] = True,
allow_unsafe: bool = False,
):
"""A default decoder for webdataset.
This handles common file extensions: .txt, .cls, .cls2,
.jpg, .png, .json, .npy, .mp, .pt, .pth, .pickle, .pkl.
These are the most common extensions used in webdataset.
For other extensions, users can provide their own decoder.
Args:
sample: sample, modified in place
format: optional image format hint (e.g. ``"PIL"`` to return PIL
images instead of numpy arrays).
allow_unsafe: if True, allow pickle/torch deserialization
Returns:
The sample with values decoded according to their key extension.
"""
sample = dict(sample)
for key, value in sample.items():
extension = key.split(".")[-1]
if key.startswith("__"):
continue
elif extension in ["txt", "text"]:
sample[key] = value.decode("utf-8")
elif extension in ["cls", "cls2"]:
sample[key] = int(value.decode("utf-8"))
elif extension in ["jpg", "png", "ppm", "pgm", "pbm", "pnm"]:
import numpy as np
import PIL.Image
if format == "PIL":
sample[key] = PIL.Image.open(io.BytesIO(value))
else:
sample[key] = np.asarray(PIL.Image.open(io.BytesIO(value)))
elif extension == "json":
sample[key] = json.loads(value)
elif extension == "npy":
import numpy as np
sample[key] = np.load(io.BytesIO(value))
elif extension == "mp":
import msgpack
sample[key] = msgpack.unpackb(value, raw=False)
elif extension in ["pt", "pth"]:
if not allow_unsafe:
raise ValueError(
f"Refusing to load .{extension} member {key!r} from "
f"WebDataset with weights_only=False (arbitrary code "
f"execution risk). Provide a custom decoder or set "
f"{ALLOW_UNSAFE_DESERIALIZATION_ENV_VAR}=1 "
f"for trusted sources."
)
import torch
sample[key] = torch.load(io.BytesIO(value), weights_only=False)
elif extension in ["pickle", "pkl"]:
if not allow_unsafe:
raise ValueError(
f"Refusing to unpickle WebDataset member {key!r} "
f"(arbitrary code execution risk). Provide a custom "
f"decoder or set "
f"{ALLOW_UNSAFE_DESERIALIZATION_ENV_VAR}=1 "
f"for trusted sources."
)
import pickle
sample[key] = pickle.loads(value)
return sample
extension_to_format = {"jpg": "jpeg"}
def _default_encoder(sample: Dict[str, Any], format: Optional[Union[str, bool]] = True):
"""A default encoder for webdataset.
This handles common file extensions: .txt, .cls, .cls2, .jpg,
.png, .json, .npy, .mp, .pt, .pth, .pickle, .pkl
These are the most common extensions used in webdataset.
For other extensions, users can provide their own encoder.
Args:
sample: sample to encode.
format: optional image format hint forwarded to the underlying
image encoder.
Returns:
The sample with values encoded according to their key extension.
"""
sample = dict(sample)
for key, value in sample.items():
extension = key.split(".")[-1]
if key.startswith("__"):
continue
elif extension in ["txt"]:
sample[key] = value.encode("utf-8")
elif extension in ["cls", "cls2"]:
sample[key] = str(value).encode("utf-8")
elif extension in ["jpg", "jpeg", "png", "ppm", "pgm", "pbm", "pnm"]:
import numpy as np
import PIL.Image
if isinstance(value, np.ndarray):
value = PIL.Image.fromarray(value)
assert isinstance(value, PIL.Image.Image)
stream = io.BytesIO()
value.save(
stream, format=extension_to_format.get(extension.lower(), extension)
)
sample[key] = stream.getvalue()
elif extension == "json":
sample[key] = json.dumps(value).encode("utf-8")
elif extension == "npy":
import numpy as np
stream = io.BytesIO()
np.save(stream, value)
sample[key] = stream.getvalue()
elif extension == "mp":
import msgpack
sample[key] = msgpack.dumps(value)
elif extension in ["pt", "pth"]:
import torch
stream = io.BytesIO()
torch.save(value, stream)
sample[key] = stream.getvalue()
elif extension in ["pickle", "pkl"]:
import pickle
stream = io.BytesIO()
pickle.dump(value, stream)
sample[key] = stream.getvalue()
return sample
def _make_iterable(block: BlockAccessor):
"""Make a block iterable.
This is a placeholder for dealing with more complex blocks.
Args:
block: Ray Dataset block
Returns:
Iterable[Dict[str,Any]]: Iterable of samples
"""
return block.iter_rows(public_row_format=False)
class WebDatasetDatasource(FileBasedDatasource):
"""A Datasource for WebDataset datasets (tar format with naming conventions)."""
_FILE_EXTENSIONS = ["tar"]
def __init__(
self,
paths: Union[str, List[str]],
decoder: Optional[Union[bool, str, callable, list]] = True,
fileselect: Optional[Union[bool, callable, list]] = None,
filerename: Optional[Union[bool, callable, list]] = None,
suffixes: Optional[Union[bool, callable, list]] = None,
verbose_open: bool = False,
expand_json: bool = False,
**file_based_datasource_kwargs,
):
super().__init__(paths, **file_based_datasource_kwargs)
self.decoder = decoder
self.fileselect = fileselect
self.filerename = filerename
self.suffixes = suffixes
self.verbose_open = verbose_open
self.expand_json = expand_json
self._allow_unsafe_deserialization = env_bool(
ALLOW_UNSAFE_DESERIALIZATION_ENV_VAR, False
)
def _read_stream(self, stream: "pyarrow.NativeFile", path: str) -> Iterator[Block]:
"""Read and decode samples from a stream.
Note that fileselect selects files during reading, while suffixes
selects files during the grouping step.
Args:
stream: File descriptor to read from.
path: Path to the data.
Yields:
Block: Single-row blocks (one per WebDataset sample).
"""
import pandas as pd
def get_tar_file_iterator():
return _tar_file_iterator(
stream,
fileselect=self.fileselect,
filerename=self.filerename,
verbose_open=self.verbose_open,
)
# S3 can raise transient errors during iteration
files = iterate_with_retry(
get_tar_file_iterator,
"iterate tar file",
match=self._data_context.retried_io_errors,
)
samples = _group_by_keys(files, meta=dict(__url__=path), suffixes=self.suffixes)
default_decoder = partial(
_default_decoder, allow_unsafe=self._allow_unsafe_deserialization
)
for sample in samples:
if self.decoder is not None:
sample = _apply_list(self.decoder, sample, default=default_decoder)
if self.expand_json:
if isinstance(sample["json"], bytes):
parsed_json = json.loads(sample["json"].decode("utf-8"))
elif isinstance(sample["json"], str):
parsed_json = json.loads(sample["json"])
elif isinstance(sample["json"], dict):
parsed_json = sample["json"]
else:
raise TypeError(
f"Unsupported data type" f" {type(sample['json'])} for sample"
)
for k, v in parsed_json.items():
if k not in sample:
sample[k] = []
sample[k].append(v)
yield pd.DataFrame(
{
k: v if isinstance(v, list) and len(v) == 1 else [v]
for k, v in sample.items()
}
)