6b7e6b44f1
Python Build and Type Check / python-ci (ubuntu-latest, 3.11) (push) Has been cancelled
Python Build and Type Check / python-ci (ubuntu-latest, 3.13) (push) Has been cancelled
Python Build and Type Check / python-ci (windows-latest, 3.11) (push) Has been cancelled
Python Build and Type Check / python-ci (windows-latest, 3.13) (push) Has been cancelled
Python Integration Tests / python-ci (ubuntu-latest, 3.13) (push) Has been cancelled
Python Integration Tests / python-ci (windows-latest, 3.13) (push) Has been cancelled
Python Notebook Tests / python-ci (ubuntu-latest, 3.13) (push) Has been cancelled
Python Notebook Tests / python-ci (windows-latest, 3.13) (push) Has been cancelled
Python Smoke Tests / python-ci (ubuntu-latest, 3.13) (push) Has been cancelled
Python Smoke Tests / python-ci (windows-latest, 3.13) (push) Has been cancelled
Python Unit Tests / python-ci (ubuntu-latest, 3.13) (push) Has been cancelled
Python Unit Tests / python-ci (windows-latest, 3.13) (push) Has been cancelled
gh-pages / build (push) Has been cancelled
Python Publish (pypi) / Upload release to PyPI (push) Has been cancelled
Spellcheck / spellcheck (push) Has been cancelled
204 lines
7.0 KiB
Python
204 lines
7.0 KiB
Python
# Copyright (c) 2025 Microsoft Corporation.
|
|
# Licensed under the MIT Licenses
|
|
|
|
"""A CSV-based implementation of the Table abstraction for streaming row access."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import inspect
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import aiofiles
|
|
|
|
from graphrag_storage.file_storage import FileStorage
|
|
from graphrag_storage.tables.table import RowTransformer, Table
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import AsyncIterator
|
|
from io import TextIOWrapper
|
|
|
|
from graphrag_storage import Storage
|
|
|
|
try:
|
|
csv.field_size_limit(sys.maxsize)
|
|
except OverflowError:
|
|
csv.field_size_limit(100 * 1024 * 1024)
|
|
|
|
|
|
def _identity(row: dict[str, Any]) -> Any:
|
|
"""Return row unchanged (default transformer)."""
|
|
return row
|
|
|
|
|
|
def _apply_transformer(transformer: RowTransformer, row: dict[str, Any]) -> Any:
|
|
"""Apply transformer to row, handling both callables and classes.
|
|
|
|
If transformer is a class (e.g., Pydantic model), calls it with **row.
|
|
Otherwise calls it with row as positional argument.
|
|
"""
|
|
if inspect.isclass(transformer):
|
|
return transformer(**row)
|
|
return transformer(row)
|
|
|
|
|
|
class CSVTable(Table):
|
|
"""Row-by-row streaming interface for CSV tables."""
|
|
|
|
def __init__(
|
|
self,
|
|
storage: Storage,
|
|
table_name: str,
|
|
transformer: RowTransformer | None = None,
|
|
truncate: bool = True,
|
|
encoding: str = "utf-8",
|
|
):
|
|
"""Initialize with storage backend and table name.
|
|
|
|
Args:
|
|
storage: Storage instance (File, Blob, or Cosmos)
|
|
table_name: Name of the table (e.g., "documents")
|
|
transformer: Optional callable to transform each row before
|
|
yielding. Receives a dict, returns a transformed dict.
|
|
Defaults to identity (no transformation).
|
|
truncate: If True (default), writes go to a temporary file
|
|
which is moved over the original on close(). This allows
|
|
safe concurrent reads from the original while writes
|
|
accumulate. If False, append to existing file.
|
|
encoding: Character encoding for reading/writing CSV files.
|
|
Defaults to "utf-8".
|
|
"""
|
|
self._storage = storage
|
|
self._table_name = table_name
|
|
self._file_key = f"{table_name}.csv"
|
|
self._transformer = transformer or _identity
|
|
self._truncate = truncate
|
|
self._encoding = encoding
|
|
self._write_file: TextIOWrapper | None = None
|
|
self._writer: csv.DictWriter | None = None
|
|
self._header_written = False
|
|
self._temp_path: Path | None = None
|
|
self._final_path: Path | None = None
|
|
|
|
def __aiter__(self) -> AsyncIterator[Any]:
|
|
"""Iterate through rows one at a time.
|
|
|
|
The transformer is applied to each row before yielding.
|
|
If transformer is a Pydantic model, yields model instances.
|
|
|
|
Yields
|
|
------
|
|
Any:
|
|
Each row as dict or transformed type (e.g., Pydantic model).
|
|
"""
|
|
return self._aiter_impl()
|
|
|
|
async def _aiter_impl(self) -> AsyncIterator[Any]:
|
|
"""Implement async iteration over rows."""
|
|
if isinstance(self._storage, FileStorage):
|
|
file_path = self._storage.get_path(self._file_key)
|
|
with Path.open(file_path, "r", encoding=self._encoding) as f:
|
|
reader = csv.DictReader(f)
|
|
for row in reader:
|
|
yield _apply_transformer(self._transformer, row)
|
|
|
|
async def length(self) -> int:
|
|
"""Return the number of rows in the table."""
|
|
if isinstance(self._storage, FileStorage):
|
|
file_path = self._storage.get_path(self._file_key)
|
|
count = 0
|
|
async with aiofiles.open(file_path, "rb") as f:
|
|
while True:
|
|
chunk = await f.read(65536)
|
|
if not chunk:
|
|
break
|
|
count += chunk.count(b"\n")
|
|
return count - 1
|
|
return 0
|
|
|
|
async def has(self, row_id: str) -> bool:
|
|
"""Check if row with given ID exists."""
|
|
async for row in self:
|
|
# Handle both dict and object (e.g., Pydantic model)
|
|
if isinstance(row, dict):
|
|
if row.get("id") == row_id:
|
|
return True
|
|
elif getattr(row, "id", None) == row_id:
|
|
return True
|
|
return False
|
|
|
|
async def write(self, row: dict[str, Any]) -> None:
|
|
"""Write a single row to the CSV file.
|
|
|
|
On first write, opens a file handle. When truncate=True, writes
|
|
go to a temporary file in the same directory; the temp file is
|
|
moved over the original in close(), making it safe to read from
|
|
the original while writes are in progress. When truncate=False,
|
|
rows are appended directly to the existing file.
|
|
|
|
Args
|
|
----
|
|
row: Dictionary representing a single row to write.
|
|
"""
|
|
if isinstance(self._storage, FileStorage) and self._write_file is None:
|
|
file_path = self._storage.get_path(self._file_key)
|
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
if self._truncate:
|
|
fd, tmp = tempfile.mkstemp(
|
|
suffix=".csv",
|
|
dir=file_path.parent,
|
|
)
|
|
os.close(fd)
|
|
self._temp_path = Path(tmp)
|
|
self._final_path = file_path
|
|
self._write_file = Path.open(
|
|
self._temp_path,
|
|
"w",
|
|
encoding=self._encoding,
|
|
newline="",
|
|
)
|
|
write_header = True
|
|
else:
|
|
file_exists = file_path.exists() and file_path.stat().st_size > 0
|
|
write_header = not file_exists
|
|
self._write_file = Path.open(
|
|
file_path,
|
|
"a",
|
|
encoding=self._encoding,
|
|
newline="",
|
|
)
|
|
|
|
self._writer = csv.DictWriter(
|
|
self._write_file,
|
|
fieldnames=list(row.keys()),
|
|
)
|
|
if write_header:
|
|
self._writer.writeheader()
|
|
self._header_written = write_header
|
|
|
|
if self._writer is not None:
|
|
self._writer.writerow(row)
|
|
|
|
async def close(self) -> None:
|
|
"""Flush buffered writes and release resources.
|
|
|
|
When truncate=True, the temp file is moved over the original
|
|
so that readers never see a partially-written file.
|
|
"""
|
|
if self._write_file is not None:
|
|
self._write_file.close()
|
|
self._write_file = None
|
|
self._writer = None
|
|
self._header_written = False
|
|
|
|
if self._temp_path is not None and self._final_path is not None:
|
|
shutil.move(str(self._temp_path), str(self._final_path))
|
|
self._temp_path = None
|
|
self._final_path = None
|