import copy
from collections.abc import Set
from pathlib import Path
from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Tuple, Union
from urllib.parse import quote_plus
import dask.dataframe
import numpy
import pandas
import pyarrow.types as patypes
from tiled.adapters.core import Adapter
from ..catalog.orm import Node
from ..storage import FileStorage, Storage
from ..structures.array import ArrayStructure, BuiltinDtype, StructDtype
from ..structures.core import Spec, StructureFamily
from ..structures.data_source import Asset, DataSource, Management
from ..structures.table import TableStructure
from ..type_aliases import JSON
from ..utils import ensure_uri, path_from_uri
from .array import ArrayAdapter
from .utils import init_adapter_from_catalog
[docs]
class CSVAdapter(Adapter[TableStructure]):
"""Adapter for tabular data stored as partitioned text (csv) files"""
structure_family = StructureFamily.table
[docs]
def __init__(
self,
data_uris: Iterable[str],
structure: Optional[TableStructure] = None,
*,
metadata: Optional[JSON] = None,
specs: Optional[List[Spec]] = None,
**kwargs: Optional[Any],
) -> None:
"""Adapter for partitioned tabular data stored as a sequence of text (csv) files
Parameters
----------
data_uris : list of uris to csv files
structure :
metadata :
specs :
kwargs : dict
any keyword arguments that can be passed to the pandas.read_csv function, e.g. names, sep, dtype, etc.
"""
self._file_paths = [path_from_uri(uri) for uri in data_uris]
self._read_csv_kwargs = kwargs
if structure is None:
ddf = dask.dataframe.read_csv(self._file_paths, **self._read_csv_kwargs)
if usecols := self._read_csv_kwargs.get("usecols"):
ddf = ddf[usecols] # Ensure the order of columns is preserved
structure = TableStructure.from_dask_dataframe(ddf)
super().__init__(structure, metadata=metadata, specs=specs)
@classmethod
def supported_storage(cls) -> Set[type[Storage]]:
return {FileStorage}
@classmethod
def from_catalog(
cls,
data_source: DataSource[TableStructure],
node: Node,
/,
**kwargs: Optional[Any],
) -> "CSVAdapter":
return init_adapter_from_catalog(cls, data_source, node, **kwargs)
@classmethod
def from_uris(
cls,
*data_uris: str,
**kwargs: Optional[Any],
) -> "CSVAdapter":
return cls(data_uris, **kwargs)
def __repr__(self) -> str:
return f"{type(self).__name__}({self._structure.columns!r})"
@classmethod
def init_storage(
cls,
storage: Storage,
data_source: DataSource[TableStructure],
path_parts: List[str],
) -> DataSource[TableStructure]:
"""Initialize partitioned CSV storage
Parameters
----------
data_uri : str
location of the dataset, should point to a folder in which partitioned csv files will be created
structure : TableStructure
description of the data structure
Returns
-------
list of assets with each element corresponding to individual partition files
"""
data_source = copy.deepcopy(data_source) # Do not mutate caller input.
data_uri = storage.uri + "".join(
f"/{quote_plus(segment)}" for segment in path_parts
)
directory = path_from_uri(data_uri)
directory.mkdir(parents=True, exist_ok=True)
assets = [
Asset(
data_uri=f"{data_uri}/partition-{i}.csv",
is_directory=False,
parameter="data_uris",
num=i,
)
for i in range(data_source.structure.npartitions)
]
data_source.assets.extend(assets)
return data_source
def append_partition(
self, partition: int, data: Union[dask.dataframe.DataFrame, pandas.DataFrame]
) -> None:
"""Append data to an existing partition
Parameters
----------
partition : int
index of the partition to be appended to
data : dask.dataframe.DataFrame or pandas.DataFrame
data to be appended
"""
uri = self._file_paths[partition]
data.to_csv(uri, index=False, mode="a", header=False)
def write_partition(
self, partition: int, data: Union[dask.dataframe.DataFrame, pandas.DataFrame]
) -> None:
"""Write data to a new partition or overwrite an existing one
Parameters
----------
partition : int
index of the partition to be appended to
data : dask.dataframe.DataFrame or pandas.DataFrame
data to be appended
"""
uri = self._file_paths[partition]
data.to_csv(uri, index=False)
def write(self, data: Union[dask.dataframe.DataFrame, pandas.DataFrame]) -> None:
"""Default writing function to a dataset with a single partition
Parameters
----------
data : dask.dataframe.DataFrame or pandas.DataFrame
data to be written
"""
if self.structure().npartitions != 1:
raise NotImplementedError
uri = self._file_paths[0]
data.to_csv(uri, index=False)
def read(self, fields: Optional[List[str]] = None) -> pandas.DataFrame:
dfs = [
self.read_partition(i, fields=fields) for i in range(len(self._file_paths))
]
return pandas.concat(dfs, axis=0)
def read_partition(
self, indx: int, fields: Optional[Union[List[str], List[int]]] = None
) -> pandas.DataFrame:
"""Read a single partition (a single csv file)
Parameters
----------
indx : int
index of the partition to read
fields : list of str or int, optional
list of columns to read from the partition
Returns
-------
pandas.DataFrame
DataFrame containing the requested columns from the partition
"""
kwargs = {**self._read_csv_kwargs}
if fields is not None:
kwargs.update({"usecols": fields})
# If we assumed any missing values when determining the structure with dask, we need
# change the dtype of the column(s) to be read to the corresponding pandas nullable dtype
# (pandas does not support the `assume_missing` parameter).
if kwargs.pop("assume_missing", False):
schema = self.structure().arrow_schema_decoded
dtypes = {
indx: field.type.to_pandas_dtype() for indx, field in enumerate(schema)
}
df = pandas.read_csv(self._file_paths[indx], dtype=dtypes, **kwargs)
else:
df = pandas.read_csv(self._file_paths[indx], **kwargs)
# Ensure the order of columns is preserved if identified by names rather than indices
if usecols := kwargs.get("usecols"):
if all(isinstance(col, str) for col in usecols):
df = df[usecols]
return df
def get(self, key: str) -> Union[ArrayAdapter, None]:
if key not in self.structure().columns:
return None
return ArrayAdapter.from_array(self.read([key])[key].values)
def generate_data_sources(
self,
mimetype: str,
dict_or_none: Callable[[TableStructure], Dict[str, str]],
item: Union[str, Path],
is_directory: bool,
) -> List[DataSource[TableStructure]]:
return [
DataSource(
structure_family=StructureFamily.table,
mimetype=mimetype,
structure=self.structure(),
parameters={},
management=Management.external,
assets=[
Asset(
data_uri=ensure_uri(item),
is_directory=is_directory,
parameter="data_uris", # <-- PLURAL!
num=0, # <-- denoting that the Adapter expects a list, and this is the first element
)
],
)
]
def __getitem__(self, key: str) -> ArrayAdapter:
# Must compute to determine shape.
return ArrayAdapter.from_array(self.read([key])[key].values)
def items(self) -> Iterator[Tuple[str, ArrayAdapter]]:
"""Iterator over table columns
Returns
-------
Tuples of column names and corresponding ArrayAdapters
"""
yield from (
(key, ArrayAdapter.from_array(self.read([key])[key].values))
for key in self._structure.columns
)
class CSVArrayAdapter(ArrayAdapter):
"""Adapter for array-type data stored as partitioned csv files"""
@classmethod
def from_catalog(
cls,
data_source: DataSource[ArrayStructure],
node: Node,
/,
**kwargs: Optional[Any],
) -> "CSVArrayAdapter":
"""Adapter for partitioned array data stored as a sequence of csv files
Parameters
----------
data_source :
node :
kwargs : dict
any keyword arguments that can be passed to the pandas.read_csv function, e.g. names, sep, dtype, etc.
"""
# Load the array lazily with Dask
file_paths = [path_from_uri(ast.data_uri) for ast in data_source.assets]
structure = data_source.structure
nrows = kwargs.pop("nrows", None) # dask doesn't accept nrows
kwargs = {"header": None, **kwargs} # no header for arrays by default
ddf = dask.dataframe.read_csv(file_paths, **kwargs).rename(columns=str)
# Ensure columns are in the same order as in the usecols parameter
if usecols := kwargs.get("usecols"):
ddf = ddf[usecols]
chunks_0: tuple[int, ...] = structure.chunks[0] # rows chunking, if not stacked
# Read as a structural array if needed; ensure the correct dtype
if isinstance(structure.data_type, StructDtype):
array = ddf.to_records(lengths=chunks_0)[list(ddf.columns)].reshape(-1, 1)
else:
array = ddf.to_dask_array(lengths=chunks_0)
array = array.astype(structure.data_type.to_numpy_dtype())
# Possibly extend or cut the table according the nrows parameter
if nrows is not None:
# TODO: this pulls all the data and can take long to compute. Instead, we can open the files and
# iterate over the rows directly, which is about 4-5 times faster for 50K rows.
# Can also just .compute() and return a np array instead
nrows_actual = len(ddf)
if nrows > nrows_actual:
padding = dask.array.zeros_like(
array, shape=(nrows - nrows_actual, *array.shape[1:])
)
array = dask.array.append(array[:nrows_actual, ...], padding, axis=0)
else:
array = array[:nrows, ...]
array = array.reshape(structure.shape).rechunk(structure.chunks)
return cls(
array,
structure,
metadata=node.metadata_,
specs=node.specs,
)
@classmethod
def from_uris(
cls,
*data_uris: str,
**kwargs: Optional[Any],
) -> "CSVArrayAdapter":
tbl_adapter = CSVAdapter.from_uris(*data_uris, **{"header": None, **kwargs})
tbl_structure = tbl_adapter.structure()
column_dtypes = tbl_structure.arrow_schema_decoded.types
# Is this a structured or a simple array
is_structured = len(set(column_dtypes)) > 1
# If any column is of string dtype, read the entire table to convert to `<Un` dtype
# Otherwise -- read just the first column to determine the number of rows and chunking
string_columns = [
patypes.is_string(_cdtype) or patypes.is_large_string(_cdtype)
for _cdtype in column_dtypes
]
# Determine the true shape and chunks for the entire dataset
# Read onlythe first column of the CSV file, if possible (i.e. no string columns)
fields = None if any(string_columns) else [0]
n_part = tbl_structure.npartitions
dfs = [tbl_adapter.read_partition(indx, fields) for indx in range(n_part)]
chunks_0 = tuple(len(df) for df in dfs)
df = pandas.concat(dfs, axis=0)
n_cols = len(tbl_structure.columns) if not is_structured else 1
true_shape, true_chunks = (sum(chunks_0), n_cols), (chunks_0, (n_cols,))
true_dtype: Union[BuiltinDtype, StructDtype]
if is_structured:
# This is a table with heterogeneous column types; construct StructDtype
_np_struct = []
column_names = tbl_structure.columns
for indx, (col_dtype, col_name, is_col_str) in enumerate(
zip(column_dtypes, column_names, string_columns)
):
if is_col_str:
# Convert strings to "<Un" dtype, df must exist since we read the entire table
_np_dtype = df.iloc[:, indx].to_numpy().astype("<U").dtype
else:
_np_dtype = col_dtype.to_pandas_dtype()
_np_struct.append((col_name, _np_dtype))
true_dtype = StructDtype.from_numpy_dtype(numpy.dtype(_np_struct))
# Construct the array object
if not any(string_columns):
# Need to read the entire array, use dask for lazy loading and proper chunking
file_paths = [path_from_uri(uri) for uri in data_uris]
ddf = dask.dataframe.read_csv(file_paths, **{"header": None, **kwargs})
if usecols := kwargs.get("usecols"):
ddf = ddf[usecols] # Ensure the order of columns is preserved
if is_structured:
array = ddf.to_records(lengths=chunks_0)[list(ddf.columns)].reshape(
-1, 1
)
array = array.astype(true_dtype.to_numpy_dtype())
else:
array = ddf.to_dask_array(lengths=chunks_0)
true_dtype = BuiltinDtype.from_numpy_dtype(array.dtype)
else:
# We have already read the data into pandas DataFrame, convert to numpy
if is_structured:
array = df.to_records(index=False).reshape(true_shape)
array = array.astype(true_dtype.to_numpy_dtype())
else:
array = df.to_numpy().astype("<U").reshape(true_shape)
true_dtype = BuiltinDtype.from_numpy_dtype(array.dtype)
# Define the structure with the correct dtype, shape, and chunking
structure = ArrayStructure(
data_type=true_dtype, shape=true_shape, chunks=true_chunks
)
return cls(array, structure)