Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:37:31 +08:00

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