Files
omniread/omniread/xlsx/parser.py

147 lines
4.6 KiB
Python

"""
# Summary
XLSX parser implementations for OmniRead.
This module provides a concrete, generic parser for Office Open XML
spreadsheets. It exposes workbook sheets as lists of string rows so
downstream consumers can interpret tabular content without depending on
openpyxl directly.
The parser is intentionally statement-agnostic: it performs no header
detection or column interpretation beyond basic cell normalization.
"""
import datetime
from io import BytesIO
from typing import List, Optional, Union
import openpyxl
from omniread.core.content import Content
from .parser_base import XlsxParserBase
class XlsxParser(XlsxParserBase):
"""
Generic xlsx parser producing string rows from a worksheet.
Notes:
**Responsibilities:**
- Lazily load the workbook owned by the parser's content.
- Normalize cells (including dates and numeric values) into
deterministic string representations.
- Expose sheet discovery and row extraction helpers.
**Constraints:**
- Cells are rendered with ``str(value)`` after trimming; date and
datetime values are rendered in ISO format. Consumers requiring
locale-specific formatting must convert on their side.
"""
def __init__(self, content: Content, *, data_only: bool = True, read_only: bool = True):
"""
Initialize the parser.
Args:
content (Content):
XLSX content to parse; its type must be supported.
data_only (bool):
Passed to openpyxl: when True, formula cells yield their last
computed value instead of the formula string.
read_only (bool):
Passed to openpyxl: streaming mode for lower memory usage.
"""
super().__init__(content)
self._data_only = data_only
self._read_only = read_only
self._workbook: Optional[openpyxl.Workbook] = None
@property
def workbook(self) -> openpyxl.Workbook:
"""
The lazily loaded workbook backing this parser's content.
"""
if self._workbook is None:
self._workbook = openpyxl.load_workbook(
BytesIO(self.content.raw),
data_only=self._data_only,
read_only=self._read_only,
)
return self._workbook
@property
def sheet_names(self) -> List[str]:
"""
Names of all worksheets contained in the workbook.
"""
return list(self.workbook.sheetnames)
def parse(self) -> List[List[str]]:
"""
Parse the first worksheet into normalized string rows.
Returns:
List[List[str]]:
Rows of the default (first) worksheet.
"""
return self.rows()
def rows(
self,
sheet: Optional[Union[int, str]] = None,
*,
skip_empty: bool = True,
) -> List[List[str]]:
"""
Extract normalized string rows from a worksheet.
Args:
sheet (Optional[Union[int, str]]):
Worksheet index or title; defaults to the first worksheet.
skip_empty (bool):
When True (default), rows whose cells are all blank are omitted.
Returns:
List[List[str]]:
Normalized rows; trailing blank cells are trimmed per row.
Raises:
ValueError:
If the requested sheet does not exist.
"""
ws = self._resolve_sheet(sheet)
out: List[List[str]] = []
for row in ws.iter_rows(values_only=True):
cells = [self._cell_str(v) for v in row]
while cells and not cells[-1].strip():
cells.pop()
if skip_empty and not any(c.strip() for c in cells):
continue
out.append(cells)
return out
def _resolve_sheet(self, sheet: Optional[Union[int, str]]):
names = self.workbook.sheetnames
if not names:
raise ValueError("Workbook contains no worksheets")
if sheet is None:
index = 0
elif isinstance(sheet, int):
index = sheet
else:
index = names.index(sheet) if sheet in names else -1
if not (0 <= index < len(names)):
raise ValueError(f"Worksheet not found: {sheet!r} (available: {names})")
return self.workbook[names[index]]
@staticmethod
def _cell_str(value) -> str:
if value is None:
return ""
if isinstance(value, (datetime.datetime, datetime.date)):
return value.isoformat()
return str(value).strip()