excelizarr 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- excelizarr/__init__.py +35 -0
- excelizarr/_pandas.py +185 -0
- excelizarr/_refs.py +226 -0
- excelizarr/_version.py +21 -0
- excelizarr/api.py +155 -0
- excelizarr/exceptions.py +15 -0
- excelizarr/io_xlsx.py +195 -0
- excelizarr/io_zarr.py +563 -0
- excelizarr/kinds.py +154 -0
- excelizarr/model.py +391 -0
- excelizarr/workbook.py +442 -0
- excelizarr-0.1.0.dist-info/METADATA +127 -0
- excelizarr-0.1.0.dist-info/RECORD +16 -0
- excelizarr-0.1.0.dist-info/WHEEL +5 -0
- excelizarr-0.1.0.dist-info/licenses/LICENSE +232 -0
- excelizarr-0.1.0.dist-info/top_level.txt +1 -0
excelizarr/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""A cloud-native representation of an ``.xlsx`` workbook, in Zarr.
|
|
2
|
+
|
|
3
|
+
A workbook becomes a zarr group, each sheet a group of aligned, chunked, typed 2D
|
|
4
|
+
arrays. Read a range straight off object storage, ``pd.read_excel`` a store without
|
|
5
|
+
unzipping any XML, and convert back to ``.xlsx`` with formulas and number formats
|
|
6
|
+
intact::
|
|
7
|
+
|
|
8
|
+
import excelizarr as xz
|
|
9
|
+
|
|
10
|
+
wb = xz.xlsx_to_zarr("model.xlsx", "s3://bucket/model.zarr")
|
|
11
|
+
wb["Inputs!B2:D10"]
|
|
12
|
+
df = xz.read_excel("s3://bucket/model.zarr", sheet_name="Inputs")
|
|
13
|
+
xz.zarr_to_xlsx("s3://bucket/model.zarr", "roundtrip.xlsx")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from . import _version
|
|
17
|
+
from .api import open_workbook, read_excel, xlsx_to_zarr, zarr_to_xlsx
|
|
18
|
+
from .exceptions import ExcelizarrError, FormatVersionError, InvalidReferenceError
|
|
19
|
+
from .kinds import CellKind
|
|
20
|
+
from .workbook import ZarrSheet, ZarrWorkbook
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"CellKind",
|
|
24
|
+
"ExcelizarrError",
|
|
25
|
+
"FormatVersionError",
|
|
26
|
+
"InvalidReferenceError",
|
|
27
|
+
"ZarrSheet",
|
|
28
|
+
"ZarrWorkbook",
|
|
29
|
+
"open_workbook",
|
|
30
|
+
"read_excel",
|
|
31
|
+
"xlsx_to_zarr",
|
|
32
|
+
"zarr_to_xlsx",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
__version__ = _version.get_versions()["version"]
|
excelizarr/_pandas.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""``pandas.read_excel``, but the "Excel file" is a zarr store.
|
|
2
|
+
|
|
3
|
+
Rather than re-implementing ``read_excel``'s argument handling (``header``,
|
|
4
|
+
``index_col``, ``usecols``, ``skiprows``, ``nrows``, ``parse_dates``, ``dtype``, ...),
|
|
5
|
+
this plugs a zarr-backed sheet source into pandas' own ``BaseExcelReader``. pandas then
|
|
6
|
+
does everything it does for a real ``.xlsx`` after the cells have been read, so the
|
|
7
|
+
result matches ``pd.read_excel(path, engine="openpyxl")`` by construction, and the
|
|
8
|
+
test suite checks that it does.
|
|
9
|
+
|
|
10
|
+
The one thing pandas asks of a reader is ``get_sheet_data``: rows of Python values,
|
|
11
|
+
converted the way its openpyxl reader converts them (empty → ``""``, error → ``NaN``,
|
|
12
|
+
integral numbers → ``int``), with trailing empty cells and rows trimmed. When pandas
|
|
13
|
+
only needs the first ``n`` rows (``nrows``), only those rows are read from the store.
|
|
14
|
+
|
|
15
|
+
``BaseExcelReader`` is pandas-internal API. It has been stable for years, but if a
|
|
16
|
+
pandas release moves it, this module is the only place that needs to change.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
from pandas.io.excel._base import BaseExcelReader # type: ignore[attr-defined]
|
|
25
|
+
|
|
26
|
+
from .kinds import CellKind, Slot
|
|
27
|
+
from .model import as_ints, decode, value_slots
|
|
28
|
+
from .workbook import ZarrSheet, ZarrWorkbook
|
|
29
|
+
|
|
30
|
+
__all__ = ["ZarrExcelReader", "sheet_data"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sheet_data(
|
|
34
|
+
sheet: ZarrSheet, file_rows_needed: int | None = None
|
|
35
|
+
) -> list[list[Any]]:
|
|
36
|
+
"""Read a sheet as pandas' openpyxl reader would hand it to its parser.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
sheet : ZarrSheet
|
|
41
|
+
The sheet.
|
|
42
|
+
file_rows_needed : int, optional
|
|
43
|
+
Read at most this many rows from the top.
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
list of list
|
|
48
|
+
Row-major cell values, rectangular, trailing empties trimmed.
|
|
49
|
+
"""
|
|
50
|
+
nrows, ncols = sheet.shape
|
|
51
|
+
if file_rows_needed is not None:
|
|
52
|
+
nrows = min(nrows, file_rows_needed)
|
|
53
|
+
if nrows == 0 or ncols == 0:
|
|
54
|
+
return []
|
|
55
|
+
slots = sheet.slots((slice(0, nrows), slice(0, ncols)))
|
|
56
|
+
kind = slots[Slot.KIND]
|
|
57
|
+
slots.prefetch(value_slots(kind))
|
|
58
|
+
vals = decode(slots)
|
|
59
|
+
|
|
60
|
+
# pandas' openpyxl reader: whole-number floats come back as ints, errors as NaN
|
|
61
|
+
is_float = kind == CellKind.FLOAT
|
|
62
|
+
if is_float.any():
|
|
63
|
+
number = slots[Slot.NUMBER]
|
|
64
|
+
whole = is_float & (number == np.floor(number))
|
|
65
|
+
if whole.any():
|
|
66
|
+
vals[whole] = as_ints(number[whole])
|
|
67
|
+
vals[kind == CellKind.ERROR] = np.nan
|
|
68
|
+
|
|
69
|
+
# pandas' openpyxl reader spells an empty cell "", and trims on that
|
|
70
|
+
empty = kind == CellKind.EMPTY
|
|
71
|
+
vals[empty] = ""
|
|
72
|
+
|
|
73
|
+
filled = ~empty
|
|
74
|
+
if not filled.any():
|
|
75
|
+
return []
|
|
76
|
+
last_row = int(np.flatnonzero(filled.any(axis=1))[-1]) + 1
|
|
77
|
+
last_col = int(np.flatnonzero(filled.any(axis=0))[-1]) + 1
|
|
78
|
+
rows: list[list[Any]] = vals[:last_row, :last_col].tolist()
|
|
79
|
+
return rows
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ZarrExcelReader(BaseExcelReader[ZarrWorkbook]):
|
|
83
|
+
"""A pandas Excel reader whose "file" is a :class:`~.workbook.ZarrWorkbook`.
|
|
84
|
+
|
|
85
|
+
Parameters
|
|
86
|
+
----------
|
|
87
|
+
workbook : ZarrWorkbook
|
|
88
|
+
The workbook to read sheets from.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, workbook: ZarrWorkbook) -> None:
|
|
92
|
+
super().__init__(workbook)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def _workbook_class(self) -> type[ZarrWorkbook]:
|
|
96
|
+
return ZarrWorkbook
|
|
97
|
+
|
|
98
|
+
def load_workbook(
|
|
99
|
+
self, filepath_or_buffer: Any, engine_kwargs: Any
|
|
100
|
+
) -> ZarrWorkbook:
|
|
101
|
+
"""Never called: the workbook is always handed over already open.
|
|
102
|
+
|
|
103
|
+
Parameters
|
|
104
|
+
----------
|
|
105
|
+
filepath_or_buffer : object
|
|
106
|
+
Unused.
|
|
107
|
+
engine_kwargs : object
|
|
108
|
+
Unused.
|
|
109
|
+
|
|
110
|
+
Returns
|
|
111
|
+
-------
|
|
112
|
+
ZarrWorkbook
|
|
113
|
+
Never returns.
|
|
114
|
+
|
|
115
|
+
Raises
|
|
116
|
+
------
|
|
117
|
+
TypeError
|
|
118
|
+
Always.
|
|
119
|
+
"""
|
|
120
|
+
raise TypeError("ZarrExcelReader takes an open ZarrWorkbook")
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def sheet_names(self) -> list[str]:
|
|
124
|
+
"""Return the workbook's sheet names, in tab order.
|
|
125
|
+
|
|
126
|
+
Returns
|
|
127
|
+
-------
|
|
128
|
+
list of str
|
|
129
|
+
Sheet names, in tab order.
|
|
130
|
+
"""
|
|
131
|
+
book: ZarrWorkbook = self.book
|
|
132
|
+
return book.sheet_names
|
|
133
|
+
|
|
134
|
+
def get_sheet_by_name(self, name: str) -> ZarrSheet:
|
|
135
|
+
"""Return a sheet by name, with pandas' error for a missing one.
|
|
136
|
+
|
|
137
|
+
Parameters
|
|
138
|
+
----------
|
|
139
|
+
name : str
|
|
140
|
+
The sheet name.
|
|
141
|
+
|
|
142
|
+
Returns
|
|
143
|
+
-------
|
|
144
|
+
ZarrSheet
|
|
145
|
+
The sheet.
|
|
146
|
+
"""
|
|
147
|
+
self.raise_if_bad_sheet_by_name(name)
|
|
148
|
+
book: ZarrWorkbook = self.book
|
|
149
|
+
return book.sheet(name)
|
|
150
|
+
|
|
151
|
+
def get_sheet_by_index(self, index: int) -> ZarrSheet:
|
|
152
|
+
"""Return a sheet by tab position, with pandas' error for a missing one.
|
|
153
|
+
|
|
154
|
+
Parameters
|
|
155
|
+
----------
|
|
156
|
+
index : int
|
|
157
|
+
Zero-based tab position.
|
|
158
|
+
|
|
159
|
+
Returns
|
|
160
|
+
-------
|
|
161
|
+
ZarrSheet
|
|
162
|
+
The sheet.
|
|
163
|
+
"""
|
|
164
|
+
self.raise_if_bad_sheet_by_index(index)
|
|
165
|
+
book: ZarrWorkbook = self.book
|
|
166
|
+
return book.sheet(index)
|
|
167
|
+
|
|
168
|
+
def get_sheet_data(
|
|
169
|
+
self, sheet: ZarrSheet, file_rows_needed: int | None = None
|
|
170
|
+
) -> list[list[Any]]:
|
|
171
|
+
"""Rows of Python values for pandas' parser; see :func:`sheet_data`.
|
|
172
|
+
|
|
173
|
+
Parameters
|
|
174
|
+
----------
|
|
175
|
+
sheet : ZarrSheet
|
|
176
|
+
The sheet.
|
|
177
|
+
file_rows_needed : int, optional
|
|
178
|
+
Read at most this many rows from the top.
|
|
179
|
+
|
|
180
|
+
Returns
|
|
181
|
+
-------
|
|
182
|
+
list of list
|
|
183
|
+
Row-major cell values.
|
|
184
|
+
"""
|
|
185
|
+
return sheet_data(sheet, file_rows_needed)
|
excelizarr/_refs.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""A1-style references: ``"B2"``, ``"A:C"``, ``"'My Sheet'!$B$2:D10"``.
|
|
2
|
+
|
|
3
|
+
Everything here works in **zero-based, half-open** coordinates, which is what the zarr
|
|
4
|
+
arrays are indexed in: ``"B2:D10"`` is rows ``1:10`` and columns ``1:4``. Whole-column
|
|
5
|
+
and whole-row ranges (``"A:C"``, ``"2:5"``) leave the other axis open (``None``), and
|
|
6
|
+
it is the caller's job to clip that to the sheet's shape.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from .exceptions import InvalidReferenceError
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"MAX_COLS",
|
|
18
|
+
"MAX_ROWS",
|
|
19
|
+
"RangeRef",
|
|
20
|
+
"col_to_index",
|
|
21
|
+
"format_range",
|
|
22
|
+
"index_to_col",
|
|
23
|
+
"parse_range",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
#: Excel's own limits: ``XFD1048576`` is the last cell of a sheet.
|
|
27
|
+
MAX_ROWS = 1_048_576
|
|
28
|
+
MAX_COLS = 16_384
|
|
29
|
+
|
|
30
|
+
_CELL = re.compile(r"^\$?([A-Za-z]{1,3})\$?([0-9]+)$")
|
|
31
|
+
_COL = re.compile(r"^\$?([A-Za-z]{1,3})$")
|
|
32
|
+
_ROW = re.compile(r"^\$?([0-9]+)$")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def col_to_index(letters: str) -> int:
|
|
36
|
+
"""Convert column letters to a zero-based index (``"A"`` is 0, ``"AA"`` is 26).
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
letters : str
|
|
41
|
+
One to three letters, either case.
|
|
42
|
+
|
|
43
|
+
Returns
|
|
44
|
+
-------
|
|
45
|
+
int
|
|
46
|
+
The zero-based column index.
|
|
47
|
+
|
|
48
|
+
Raises
|
|
49
|
+
------
|
|
50
|
+
InvalidReferenceError
|
|
51
|
+
If ``letters`` isn't a column Excel could have.
|
|
52
|
+
"""
|
|
53
|
+
if (
|
|
54
|
+
not letters
|
|
55
|
+
or not letters.isascii()
|
|
56
|
+
or not letters.isalpha()
|
|
57
|
+
or len(letters) > 3
|
|
58
|
+
):
|
|
59
|
+
raise InvalidReferenceError(f"not a column: {letters!r}")
|
|
60
|
+
n = 0
|
|
61
|
+
for ch in letters.upper():
|
|
62
|
+
n = n * 26 + (ord(ch) - ord("A") + 1)
|
|
63
|
+
if n > MAX_COLS:
|
|
64
|
+
raise InvalidReferenceError(f"column {letters!r} is past XFD")
|
|
65
|
+
return n - 1
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def index_to_col(index: int) -> str:
|
|
69
|
+
"""Convert a zero-based column index to letters (0 is ``"A"``, 26 is ``"AA"``).
|
|
70
|
+
|
|
71
|
+
Parameters
|
|
72
|
+
----------
|
|
73
|
+
index : int
|
|
74
|
+
A column index in ``[0, MAX_COLS)``.
|
|
75
|
+
|
|
76
|
+
Returns
|
|
77
|
+
-------
|
|
78
|
+
str
|
|
79
|
+
The upper-case column letters.
|
|
80
|
+
|
|
81
|
+
Raises
|
|
82
|
+
------
|
|
83
|
+
InvalidReferenceError
|
|
84
|
+
If ``index`` is outside the sheet.
|
|
85
|
+
"""
|
|
86
|
+
if not 0 <= index < MAX_COLS:
|
|
87
|
+
raise InvalidReferenceError(f"column index {index} is outside the sheet")
|
|
88
|
+
out = ""
|
|
89
|
+
n = index + 1
|
|
90
|
+
while n:
|
|
91
|
+
n, rem = divmod(n - 1, 26)
|
|
92
|
+
out = chr(ord("A") + rem) + out
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class RangeRef:
|
|
98
|
+
"""A parsed range: an optional sheet plus half-open, zero-based row/col bounds.
|
|
99
|
+
|
|
100
|
+
``None`` on a bound means "open on that side": ``"A:C"`` has ``row_start`` and
|
|
101
|
+
``row_stop`` both ``None``.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
sheet: str | None
|
|
105
|
+
row_start: int | None
|
|
106
|
+
row_stop: int | None
|
|
107
|
+
col_start: int | None
|
|
108
|
+
col_stop: int | None
|
|
109
|
+
|
|
110
|
+
def resolve(self, shape: tuple[int, int]) -> tuple[slice, slice]:
|
|
111
|
+
"""Clip the open bounds to a sheet ``shape`` and return two slices.
|
|
112
|
+
|
|
113
|
+
Parameters
|
|
114
|
+
----------
|
|
115
|
+
shape : tuple of int
|
|
116
|
+
The ``(rows, cols)`` extent of the sheet.
|
|
117
|
+
|
|
118
|
+
Returns
|
|
119
|
+
-------
|
|
120
|
+
tuple of slice
|
|
121
|
+
``(rows, cols)`` slices, never reaching past ``shape``.
|
|
122
|
+
"""
|
|
123
|
+
nrows, ncols = shape
|
|
124
|
+
r0 = 0 if self.row_start is None else min(self.row_start, nrows)
|
|
125
|
+
r1 = nrows if self.row_stop is None else min(self.row_stop, nrows)
|
|
126
|
+
c0 = 0 if self.col_start is None else min(self.col_start, ncols)
|
|
127
|
+
c1 = ncols if self.col_stop is None else min(self.col_stop, ncols)
|
|
128
|
+
return slice(r0, max(r0, r1)), slice(c0, max(c0, c1))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _split_sheet(ref: str) -> tuple[str | None, str]:
|
|
132
|
+
if "!" not in ref:
|
|
133
|
+
return None, ref
|
|
134
|
+
sheet, _, rest = ref.rpartition("!")
|
|
135
|
+
if len(sheet) >= 2 and sheet[0] == sheet[-1] == "'":
|
|
136
|
+
sheet = sheet[1:-1].replace("''", "'")
|
|
137
|
+
if not sheet:
|
|
138
|
+
raise InvalidReferenceError(f"empty sheet name in {ref!r}")
|
|
139
|
+
return sheet, rest
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _parse_endpoint(part: str) -> tuple[int | None, int | None]:
|
|
143
|
+
"""Parse one side of a range into (row, col), either of which may be open."""
|
|
144
|
+
if m := _CELL.match(part):
|
|
145
|
+
row = int(m.group(2))
|
|
146
|
+
if not 1 <= row <= MAX_ROWS:
|
|
147
|
+
raise InvalidReferenceError(f"row {row} is outside the sheet")
|
|
148
|
+
return row - 1, col_to_index(m.group(1))
|
|
149
|
+
if m := _COL.match(part):
|
|
150
|
+
return None, col_to_index(m.group(1))
|
|
151
|
+
if m := _ROW.match(part):
|
|
152
|
+
row = int(m.group(1))
|
|
153
|
+
if not 1 <= row <= MAX_ROWS:
|
|
154
|
+
raise InvalidReferenceError(f"row {row} is outside the sheet")
|
|
155
|
+
return row - 1, None
|
|
156
|
+
raise InvalidReferenceError(f"not a cell, column or row: {part!r}")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def parse_range(ref: str) -> RangeRef:
|
|
160
|
+
"""Parse an A1-style reference into a :class:`RangeRef`.
|
|
161
|
+
|
|
162
|
+
Accepts a single cell (``"B2"``), a rectangle (``"B2:D10"``, either corner first),
|
|
163
|
+
whole columns (``"A:C"``), whole rows (``"2:5"``), ``$`` absolute markers and an
|
|
164
|
+
optional sheet prefix, quoted the way Excel quotes it (``"'Q1 ''24'!A1"``).
|
|
165
|
+
|
|
166
|
+
Parameters
|
|
167
|
+
----------
|
|
168
|
+
ref : str
|
|
169
|
+
The reference to parse.
|
|
170
|
+
|
|
171
|
+
Returns
|
|
172
|
+
-------
|
|
173
|
+
RangeRef
|
|
174
|
+
Zero-based, half-open bounds.
|
|
175
|
+
|
|
176
|
+
Raises
|
|
177
|
+
------
|
|
178
|
+
InvalidReferenceError
|
|
179
|
+
If ``ref`` doesn't parse, or mixes a column-only end with a row-only end.
|
|
180
|
+
"""
|
|
181
|
+
sheet, body = _split_sheet(ref.strip())
|
|
182
|
+
parts = body.split(":")
|
|
183
|
+
if len(parts) == 1:
|
|
184
|
+
if not _CELL.match(parts[0]):
|
|
185
|
+
raise InvalidReferenceError(f"not a cell reference: {ref!r}")
|
|
186
|
+
parts = [parts[0], parts[0]]
|
|
187
|
+
if len(parts) != 2:
|
|
188
|
+
raise InvalidReferenceError(f"too many ':' in {ref!r}")
|
|
189
|
+
(ra, ca), (rb, cb) = (_parse_endpoint(p) for p in parts)
|
|
190
|
+
if (ra is None) != (rb is None) or (ca is None) != (cb is None):
|
|
191
|
+
raise InvalidReferenceError(f"mismatched range ends in {ref!r}")
|
|
192
|
+
if ra is None and ca is None: # pragma: no cover - unreachable by the regexes
|
|
193
|
+
raise InvalidReferenceError(f"empty range {ref!r}")
|
|
194
|
+
|
|
195
|
+
def bounds(a: int | None, b: int | None) -> tuple[int | None, int | None]:
|
|
196
|
+
if a is None or b is None:
|
|
197
|
+
return None, None
|
|
198
|
+
return min(a, b), max(a, b) + 1
|
|
199
|
+
|
|
200
|
+
r0, r1 = bounds(ra, rb)
|
|
201
|
+
c0, c1 = bounds(ca, cb)
|
|
202
|
+
return RangeRef(sheet, r0, r1, c0, c1)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def format_range(row_start: int, row_stop: int, col_start: int, col_stop: int) -> str:
|
|
206
|
+
"""Format half-open zero-based bounds back to an A1 range (``"B2:D10"``).
|
|
207
|
+
|
|
208
|
+
Parameters
|
|
209
|
+
----------
|
|
210
|
+
row_start : int
|
|
211
|
+
First row, zero-based.
|
|
212
|
+
row_stop : int
|
|
213
|
+
One past the last row.
|
|
214
|
+
col_start : int
|
|
215
|
+
First column, zero-based.
|
|
216
|
+
col_stop : int
|
|
217
|
+
One past the last column.
|
|
218
|
+
|
|
219
|
+
Returns
|
|
220
|
+
-------
|
|
221
|
+
str
|
|
222
|
+
The A1 range, or a single cell reference when the range is one cell.
|
|
223
|
+
"""
|
|
224
|
+
top_left = f"{index_to_col(col_start)}{row_start + 1}"
|
|
225
|
+
bottom_right = f"{index_to_col(col_stop - 1)}{row_stop}"
|
|
226
|
+
return top_left if top_left == bottom_right else f"{top_left}:{bottom_right}"
|
excelizarr/_version.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
|
|
2
|
+
# This file was generated by 'versioneer.py' (0.29) from
|
|
3
|
+
# revision-control system data, or from the parent directory name of an
|
|
4
|
+
# unpacked source archive. Distribution tarballs contain a pre-generated copy
|
|
5
|
+
# of this file.
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
version_json = '''
|
|
10
|
+
{
|
|
11
|
+
"date": "2026-10-12T17:45:19+1100",
|
|
12
|
+
"dirty": false,
|
|
13
|
+
"error": null,
|
|
14
|
+
"full-revisionid": "803bf2468fccbd6a42dd13476df85b208c3a2899",
|
|
15
|
+
"version": "0.1.0"
|
|
16
|
+
}
|
|
17
|
+
''' # END VERSION_JSON
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_versions():
|
|
21
|
+
return json.loads(version_json)
|
excelizarr/api.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""The top-level verbs: convert, open, read, convert back."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import zarr
|
|
9
|
+
from zarr.storage import StoreLike
|
|
10
|
+
|
|
11
|
+
from .io_xlsx import Engine, load_xlsx
|
|
12
|
+
from .io_zarr import DEFAULT_CHUNKS, DEFAULT_SHARDS, open_root, write_model
|
|
13
|
+
from .workbook import ZarrWorkbook
|
|
14
|
+
|
|
15
|
+
__all__ = ["open_workbook", "read_excel", "xlsx_to_zarr", "zarr_to_xlsx"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def xlsx_to_zarr(
|
|
19
|
+
src: str | os.PathLike[str],
|
|
20
|
+
store: StoreLike,
|
|
21
|
+
*,
|
|
22
|
+
engine: Engine = "openpyxl",
|
|
23
|
+
chunks: tuple[int, int] = DEFAULT_CHUNKS,
|
|
24
|
+
shards: tuple[int, int] | None = DEFAULT_SHARDS,
|
|
25
|
+
mode: str = "w-",
|
|
26
|
+
storage_options: dict[str, Any] | None = None,
|
|
27
|
+
) -> ZarrWorkbook:
|
|
28
|
+
"""Convert an ``.xlsx`` file into a zarr workbook store.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
src : str or os.PathLike
|
|
33
|
+
The ``.xlsx`` file.
|
|
34
|
+
store : StoreLike
|
|
35
|
+
Where to write: a path, an ``s3://``/``gs://`` URL, a ``MemoryStore``, a
|
|
36
|
+
``ZipStore``, an Icechunk session's ``store``, ...
|
|
37
|
+
engine : {"openpyxl", "calamine"}
|
|
38
|
+
``openpyxl`` keeps formulas, number formats and layout; ``calamine`` (the
|
|
39
|
+
``fast`` extra) keeps values only, and is much faster.
|
|
40
|
+
chunks : tuple of int
|
|
41
|
+
``(rows, cols)`` per chunk.
|
|
42
|
+
shards : tuple of int or None
|
|
43
|
+
``(rows, cols)`` per shard, or ``None`` to write every chunk as its own
|
|
44
|
+
object.
|
|
45
|
+
mode : str
|
|
46
|
+
``"w-"`` (default) refuses to overwrite an existing store; ``"w"`` replaces it.
|
|
47
|
+
storage_options : dict, optional
|
|
48
|
+
Passed through to fsspec for URL stores.
|
|
49
|
+
|
|
50
|
+
Returns
|
|
51
|
+
-------
|
|
52
|
+
ZarrWorkbook
|
|
53
|
+
The written workbook, open for reading.
|
|
54
|
+
"""
|
|
55
|
+
model = load_xlsx(src, engine=engine)
|
|
56
|
+
root = write_model(
|
|
57
|
+
model,
|
|
58
|
+
store,
|
|
59
|
+
chunks=chunks,
|
|
60
|
+
shards=shards,
|
|
61
|
+
mode=mode,
|
|
62
|
+
storage_options=storage_options,
|
|
63
|
+
)
|
|
64
|
+
return ZarrWorkbook(root)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def open_workbook(
|
|
68
|
+
store: StoreLike | zarr.Group,
|
|
69
|
+
*,
|
|
70
|
+
mode: str = "r",
|
|
71
|
+
storage_options: dict[str, Any] | None = None,
|
|
72
|
+
) -> ZarrWorkbook:
|
|
73
|
+
"""Open a zarr workbook store lazily.
|
|
74
|
+
|
|
75
|
+
Parameters
|
|
76
|
+
----------
|
|
77
|
+
store : StoreLike or zarr.Group
|
|
78
|
+
The store, or its already-open root group.
|
|
79
|
+
mode : str
|
|
80
|
+
``"r"`` (default) for read-only; ``"r+"`` to allow in-place cell writes.
|
|
81
|
+
storage_options : dict, optional
|
|
82
|
+
Passed through to fsspec for URL stores.
|
|
83
|
+
|
|
84
|
+
Returns
|
|
85
|
+
-------
|
|
86
|
+
ZarrWorkbook
|
|
87
|
+
The workbook. Nothing but its root attributes has been read yet.
|
|
88
|
+
"""
|
|
89
|
+
return ZarrWorkbook(open_root(store, mode=mode, storage_options=storage_options))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def read_excel(
|
|
93
|
+
store: StoreLike | zarr.Group | ZarrWorkbook,
|
|
94
|
+
sheet_name: str | int | list[str | int] | None = 0,
|
|
95
|
+
*,
|
|
96
|
+
storage_options: dict[str, Any] | None = None,
|
|
97
|
+
**kwargs: Any,
|
|
98
|
+
) -> Any:
|
|
99
|
+
"""``pandas.read_excel``, reading from a zarr workbook store instead of ``.xlsx``.
|
|
100
|
+
|
|
101
|
+
pandas' own argument handling runs on the cells, so every keyword ``read_excel``
|
|
102
|
+
takes works the same way here, and the frame you get is the frame you'd have got
|
|
103
|
+
from the original file.
|
|
104
|
+
|
|
105
|
+
Parameters
|
|
106
|
+
----------
|
|
107
|
+
store : StoreLike, zarr.Group or ZarrWorkbook
|
|
108
|
+
The workbook.
|
|
109
|
+
sheet_name : str, int, list or None
|
|
110
|
+
As for ``pandas.read_excel``: a name, a position, a list of either (returns a
|
|
111
|
+
dict), or ``None`` for every sheet (returns a dict).
|
|
112
|
+
storage_options : dict, optional
|
|
113
|
+
Passed through to fsspec for URL stores.
|
|
114
|
+
**kwargs
|
|
115
|
+
Anything else ``pandas.read_excel`` takes: ``header``, ``index_col``,
|
|
116
|
+
``usecols``, ``skiprows``, ``nrows``, ``dtype``, ``parse_dates``, ...
|
|
117
|
+
|
|
118
|
+
Returns
|
|
119
|
+
-------
|
|
120
|
+
pandas.DataFrame or dict of pandas.DataFrame
|
|
121
|
+
As ``pandas.read_excel`` would return.
|
|
122
|
+
"""
|
|
123
|
+
from ._pandas import ZarrExcelReader
|
|
124
|
+
|
|
125
|
+
wb = (
|
|
126
|
+
store
|
|
127
|
+
if isinstance(store, ZarrWorkbook)
|
|
128
|
+
else open_workbook(store, storage_options=storage_options)
|
|
129
|
+
)
|
|
130
|
+
return ZarrExcelReader(wb).parse(sheet_name=sheet_name, **kwargs)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def zarr_to_xlsx(
|
|
134
|
+
store: StoreLike | zarr.Group | ZarrWorkbook,
|
|
135
|
+
dst: str | os.PathLike[str],
|
|
136
|
+
*,
|
|
137
|
+
storage_options: dict[str, Any] | None = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Convert a zarr workbook store back into an ``.xlsx`` file.
|
|
140
|
+
|
|
141
|
+
Parameters
|
|
142
|
+
----------
|
|
143
|
+
store : StoreLike, zarr.Group or ZarrWorkbook
|
|
144
|
+
The workbook.
|
|
145
|
+
dst : str or os.PathLike
|
|
146
|
+
The ``.xlsx`` file to write.
|
|
147
|
+
storage_options : dict, optional
|
|
148
|
+
Passed through to fsspec for URL stores.
|
|
149
|
+
"""
|
|
150
|
+
wb = (
|
|
151
|
+
store
|
|
152
|
+
if isinstance(store, ZarrWorkbook)
|
|
153
|
+
else open_workbook(store, storage_options=storage_options)
|
|
154
|
+
)
|
|
155
|
+
wb.to_xlsx(dst)
|
excelizarr/exceptions.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""The exceptions excelizarr raises on purpose."""
|
|
2
|
+
|
|
3
|
+
__all__ = ["ExcelizarrError", "FormatVersionError", "InvalidReferenceError"]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ExcelizarrError(Exception):
|
|
7
|
+
"""Base class for every error excelizarr raises deliberately."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InvalidReferenceError(ExcelizarrError, ValueError):
|
|
11
|
+
"""An A1-style cell or range reference that doesn't parse, or names no sheet."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FormatVersionError(ExcelizarrError):
|
|
15
|
+
"""A store that isn't an excelizarr workbook, or is one from a format we can't read."""
|