headless-spreadsheets 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.
- headless_spreadsheets/__init__.py +14 -0
- headless_spreadsheets/core.py +225 -0
- headless_spreadsheets/errors.py +17 -0
- headless_spreadsheets/file_io.py +60 -0
- headless_spreadsheets/formats.py +33 -0
- headless_spreadsheets/py.typed +0 -0
- headless_spreadsheets/rendering.py +160 -0
- headless_spreadsheets/serialization.py +222 -0
- headless_spreadsheets/table.py +414 -0
- headless_spreadsheets-0.1.0.dist-info/METADATA +135 -0
- headless_spreadsheets-0.1.0.dist-info/RECORD +13 -0
- headless_spreadsheets-0.1.0.dist-info/WHEEL +4 -0
- headless_spreadsheets-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Spreadsheet-native tables and portable interchange for ordinary Python."""
|
|
2
|
+
|
|
3
|
+
from .core import Cell, CellInput, DataType, Limits, Scalar, Sheet, Workbook, column_name, split_address
|
|
4
|
+
from .errors import AddressError, FormatError, ShapeError, SpreadsheetError
|
|
5
|
+
from .table import Column, Row, Table, TableView
|
|
6
|
+
from .rendering import TableDocument
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AddressError", "Cell", "CellInput", "DataType", "FormatError", "Limits",
|
|
12
|
+
"Scalar", "ShapeError", "Sheet", "SpreadsheetError", "Workbook",
|
|
13
|
+
"Column", "Row", "Table", "TableDocument", "TableView", "column_name", "split_address",
|
|
14
|
+
]
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Typed cells, rectangular grids and ordered workbooks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Iterable, Iterator
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import date, datetime
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from .errors import AddressError, ShapeError, SpreadsheetError
|
|
14
|
+
from .formats import NativeJSON
|
|
15
|
+
|
|
16
|
+
type Scalar = None | bool | int | float | str | Decimal | date | datetime
|
|
17
|
+
type CellInput = Scalar | Cell
|
|
18
|
+
type DataType = Literal["blank", "boolean", "integer", "number", "text", "decimal", "date", "datetime"]
|
|
19
|
+
|
|
20
|
+
_TYPES: dict[type, DataType] = {
|
|
21
|
+
type(None): "blank", bool: "boolean", int: "integer", float: "number",
|
|
22
|
+
str: "text", Decimal: "decimal", date: "date", datetime: "datetime",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class Cell:
|
|
28
|
+
"""A literal or an opaque formula with an unverified cached scalar result.
|
|
29
|
+
|
|
30
|
+
A string beginning with '=' is literal unless supplied as ``formula``.
|
|
31
|
+
Formula results are never calculated or marked fresh in this release.
|
|
32
|
+
``number_format`` is an inert presentation hint.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
value: Scalar = None
|
|
36
|
+
formula: str | None = None
|
|
37
|
+
number_format: str | None = None
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
if type(self.value) not in _TYPES:
|
|
41
|
+
raise SpreadsheetError(f"Unsupported cell type: {type(self.value).__name__}")
|
|
42
|
+
if type(self.value) is float and not math.isfinite(self.value):
|
|
43
|
+
raise SpreadsheetError("Cell numbers must be finite.")
|
|
44
|
+
if type(self.value) is Decimal and not self.value.is_finite():
|
|
45
|
+
raise SpreadsheetError("Cell decimals must be finite.")
|
|
46
|
+
if self.formula is not None and (
|
|
47
|
+
not isinstance(self.formula, str)
|
|
48
|
+
or not self.formula.startswith("=")
|
|
49
|
+
or not self.formula[1:].strip()
|
|
50
|
+
):
|
|
51
|
+
raise SpreadsheetError("A formula must start with '=' and contain an expression.")
|
|
52
|
+
if self.number_format is not None and not isinstance(self.number_format, str):
|
|
53
|
+
raise SpreadsheetError("A number-format hint must be text or None.")
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def data_type(self) -> DataType:
|
|
57
|
+
return _TYPES[type(self.value)]
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def cache_status(self) -> Literal["value", "missing", "stale"]:
|
|
61
|
+
if self.formula is None:
|
|
62
|
+
return "value"
|
|
63
|
+
return "missing" if self.value is None else "stale"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def as_cell(value: CellInput) -> Cell:
|
|
67
|
+
return value if isinstance(value, Cell) else Cell(value)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class Limits:
|
|
72
|
+
"""Finite application guardrails, not a hard memory or CPU sandbox."""
|
|
73
|
+
|
|
74
|
+
max_rows: int = 100_000
|
|
75
|
+
max_columns: int = 1_024
|
|
76
|
+
max_cells: int = 1_000_000
|
|
77
|
+
max_input_bytes: int = 16_000_000
|
|
78
|
+
max_sheets: int = 256
|
|
79
|
+
|
|
80
|
+
def __post_init__(self) -> None:
|
|
81
|
+
for name in self.__dataclass_fields__:
|
|
82
|
+
value = getattr(self, name)
|
|
83
|
+
if type(value) is not int or value <= 0:
|
|
84
|
+
raise ShapeError(f"{name} must be a positive integer.")
|
|
85
|
+
|
|
86
|
+
def check(self, rows: int, columns: int) -> None:
|
|
87
|
+
if rows > self.max_rows or columns > self.max_columns or rows * columns > self.max_cells:
|
|
88
|
+
raise ShapeError("Grid dimensions exceed the configured limits.")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
DEFAULT_LIMITS = Limits()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def column_name(index: int) -> str:
|
|
95
|
+
"""Convert a zero-based column index to its A1 name."""
|
|
96
|
+
if type(index) is not int or index < 0:
|
|
97
|
+
raise AddressError("A column index must be a nonnegative integer.")
|
|
98
|
+
result = ""
|
|
99
|
+
index += 1
|
|
100
|
+
while index:
|
|
101
|
+
index, remainder = divmod(index - 1, 26)
|
|
102
|
+
result = chr(65 + remainder) + result
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def split_address(address: str) -> tuple[int, int]:
|
|
107
|
+
"""Return zero-based (row, column) coordinates for an A1 address."""
|
|
108
|
+
if not isinstance(address, str) or len(address) > 64:
|
|
109
|
+
raise AddressError("Expected an A1 cell address.")
|
|
110
|
+
match = re.fullmatch(r"([A-Za-z]+)([1-9][0-9]*)", address)
|
|
111
|
+
if match is None:
|
|
112
|
+
raise AddressError(f"Invalid A1 address: {address!r}")
|
|
113
|
+
column = 0
|
|
114
|
+
for letter in match[1].upper():
|
|
115
|
+
column = column * 26 + ord(letter) - 64
|
|
116
|
+
return int(match[2]) - 1, column - 1
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _collect_rows(
|
|
120
|
+
rows: Iterable[Iterable[CellInput]], *, width: int | None, limits: Limits
|
|
121
|
+
) -> tuple[tuple[tuple[Cell, ...], ...], int]:
|
|
122
|
+
if width is not None and (type(width) is not int or width < 0):
|
|
123
|
+
raise ShapeError("Grid width must be a nonnegative integer.")
|
|
124
|
+
limits.check(0, width or 0)
|
|
125
|
+
collected = []
|
|
126
|
+
for row in rows:
|
|
127
|
+
limits.check(len(collected) + 1, width or 0)
|
|
128
|
+
if isinstance(row, (str, bytes)):
|
|
129
|
+
raise ShapeError("A row must be an iterable of cells, not text.")
|
|
130
|
+
cells = []
|
|
131
|
+
for value in row:
|
|
132
|
+
limits.check(len(collected) + 1, len(cells) + 1)
|
|
133
|
+
if width is not None and len(cells) >= width:
|
|
134
|
+
raise ShapeError("Rows must all have the declared width.")
|
|
135
|
+
cells.append(as_cell(value))
|
|
136
|
+
if width is None:
|
|
137
|
+
width = len(cells)
|
|
138
|
+
if len(cells) != width:
|
|
139
|
+
raise ShapeError("Rows must all have the declared width.")
|
|
140
|
+
collected.append(tuple(cells))
|
|
141
|
+
return tuple(collected), width or 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class Sheet(NativeJSON):
|
|
145
|
+
"""An editable rectangular grid; edits never resize or calculate the grid."""
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self, rows: Iterable[Iterable[CellInput]] = (), *, name: str = "Sheet1",
|
|
149
|
+
width: int | None = None, limits: Limits = DEFAULT_LIMITS,
|
|
150
|
+
) -> None:
|
|
151
|
+
if not isinstance(name, str) or not name.strip():
|
|
152
|
+
raise ShapeError("A sheet name must be nonempty text.")
|
|
153
|
+
self._name = name
|
|
154
|
+
self._limits = limits
|
|
155
|
+
self._rows, self._width = _collect_rows(rows, width=width, limits=limits)
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def name(self) -> str:
|
|
159
|
+
return self._name
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def shape(self) -> tuple[int, int]:
|
|
163
|
+
return len(self._rows), self._width
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def rows(self) -> tuple[tuple[Cell, ...], ...]:
|
|
167
|
+
return self._rows
|
|
168
|
+
|
|
169
|
+
def __len__(self) -> int:
|
|
170
|
+
return len(self._rows)
|
|
171
|
+
|
|
172
|
+
def _position(self, address: str) -> tuple[int, int]:
|
|
173
|
+
row, column = split_address(address)
|
|
174
|
+
if row >= len(self._rows) or column >= self._width:
|
|
175
|
+
raise AddressError(f"Address {address!r} is outside the existing grid.")
|
|
176
|
+
return row, column
|
|
177
|
+
|
|
178
|
+
def cell(self, address: str) -> Cell:
|
|
179
|
+
row, column = self._position(address)
|
|
180
|
+
return self._rows[row][column]
|
|
181
|
+
|
|
182
|
+
def set(self, address: str, value: CellInput) -> None:
|
|
183
|
+
row, column = self._position(address)
|
|
184
|
+
cell = as_cell(value)
|
|
185
|
+
changed = (*self._rows[row][:column], cell, *self._rows[row][column + 1:])
|
|
186
|
+
self._rows = (*self._rows[:row], changed, *self._rows[row + 1:])
|
|
187
|
+
|
|
188
|
+
def set_formula(self, address: str, formula: str, *, cached_value: Scalar = None) -> None:
|
|
189
|
+
self.set(address, Cell(cached_value, formula=formula))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class Workbook(NativeJSON):
|
|
193
|
+
"""Named sheets in insertion order, without a calculation scheduler."""
|
|
194
|
+
|
|
195
|
+
def __init__(self, sheets: Iterable[Sheet] = (), *, limits: Limits = DEFAULT_LIMITS) -> None:
|
|
196
|
+
self._limits = limits
|
|
197
|
+
self._sheets: dict[str, Sheet] = {}
|
|
198
|
+
for sheet in sheets:
|
|
199
|
+
self.add_sheet(sheet)
|
|
200
|
+
|
|
201
|
+
def add_sheet(self, sheet: Sheet) -> None:
|
|
202
|
+
if not isinstance(sheet, Sheet):
|
|
203
|
+
raise ShapeError("A workbook contains Sheet objects.")
|
|
204
|
+
if sheet.name in self._sheets:
|
|
205
|
+
raise ShapeError(f"Duplicate sheet name: {sheet.name!r}")
|
|
206
|
+
if len(self._sheets) >= self._limits.max_sheets:
|
|
207
|
+
raise ShapeError("Workbook sheet count exceeds the configured limit.")
|
|
208
|
+
self._limits.check(*sheet.shape)
|
|
209
|
+
total = sum(rows * columns for rows, columns in (item.shape for item in self._sheets.values()))
|
|
210
|
+
if total + sheet.shape[0] * sheet.shape[1] > self._limits.max_cells:
|
|
211
|
+
raise ShapeError("Workbook cell count exceeds the configured limit.")
|
|
212
|
+
self._sheets[sheet.name] = sheet
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def sheet_names(self) -> tuple[str, ...]:
|
|
216
|
+
return tuple(self._sheets)
|
|
217
|
+
|
|
218
|
+
def __getitem__(self, name: str) -> Sheet:
|
|
219
|
+
return self._sheets[name]
|
|
220
|
+
|
|
221
|
+
def __iter__(self) -> Iterator[Sheet]:
|
|
222
|
+
return iter(self._sheets.values())
|
|
223
|
+
|
|
224
|
+
def __len__(self) -> int:
|
|
225
|
+
return len(self._sheets)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Errors raised by the public spreadsheet interfaces."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SpreadsheetError(ValueError):
|
|
5
|
+
"""Base class for invalid spreadsheet data or operations."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AddressError(SpreadsheetError):
|
|
9
|
+
"""An A1 address is malformed or outside the existing grid."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ShapeError(SpreadsheetError):
|
|
13
|
+
"""A grid is ragged, has invalid labels or exceeds its limits."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FormatError(SpreadsheetError):
|
|
17
|
+
"""Serialized spreadsheet data does not satisfy the format contract."""
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Bounded UTF-8 reads and atomic text replacement."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from .errors import FormatError
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .core import Limits
|
|
14
|
+
|
|
15
|
+
type FilePath = str | os.PathLike[str]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def check_text(text: str, limits: Limits) -> None:
|
|
19
|
+
if not isinstance(text, str):
|
|
20
|
+
raise FormatError("Expected serialized text, not a path or bytes.")
|
|
21
|
+
try:
|
|
22
|
+
if len(text) > limits.max_input_bytes or len(text.encode("utf-8")) > limits.max_input_bytes:
|
|
23
|
+
raise FormatError("Serialized input exceeds the configured byte limit.")
|
|
24
|
+
except UnicodeError as error:
|
|
25
|
+
raise FormatError("Serialized text must be valid UTF-8.") from error
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def read_text(path: FilePath, limits: Limits) -> str:
|
|
29
|
+
path = Path(path)
|
|
30
|
+
if path.stat().st_size > limits.max_input_bytes:
|
|
31
|
+
raise FormatError("Input file exceeds the configured byte limit.")
|
|
32
|
+
with path.open("rb") as stream:
|
|
33
|
+
raw = stream.read(limits.max_input_bytes + 1)
|
|
34
|
+
if len(raw) > limits.max_input_bytes:
|
|
35
|
+
raise FormatError("Input file exceeds the configured byte limit.")
|
|
36
|
+
try:
|
|
37
|
+
return raw.decode("utf-8-sig")
|
|
38
|
+
except UnicodeError as error:
|
|
39
|
+
raise FormatError("Input file must be UTF-8 text.") from error
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def write_text(text: str, path: FilePath | None) -> str:
|
|
43
|
+
if path is None:
|
|
44
|
+
return text
|
|
45
|
+
destination = Path(path)
|
|
46
|
+
temporary = None
|
|
47
|
+
try:
|
|
48
|
+
with tempfile.NamedTemporaryFile(
|
|
49
|
+
mode="w", encoding="utf-8", newline="", dir=destination.parent,
|
|
50
|
+
prefix=f".{destination.name}.", suffix=".tmp", delete=False,
|
|
51
|
+
) as stream:
|
|
52
|
+
temporary = Path(stream.name)
|
|
53
|
+
stream.write(text)
|
|
54
|
+
stream.flush()
|
|
55
|
+
os.fsync(stream.fileno())
|
|
56
|
+
os.replace(temporary, destination)
|
|
57
|
+
finally:
|
|
58
|
+
if temporary is not None:
|
|
59
|
+
temporary.unlink(missing_ok=True)
|
|
60
|
+
return text
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Lazy serialization interfaces shared by the document models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Self
|
|
6
|
+
|
|
7
|
+
from .errors import FormatError
|
|
8
|
+
from .file_io import FilePath, read_text, write_text
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .core import Limits
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NativeJSON:
|
|
15
|
+
def to_json(self, path: FilePath | None = None, *, indent: int | None = 2) -> str:
|
|
16
|
+
"""Serialize typed cells; optionally replace a UTF-8 destination atomically."""
|
|
17
|
+
from .serialization import dumps
|
|
18
|
+
return write_text(dumps(self, indent=indent), path)
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def from_json(cls, text: str, *, limits: Limits | None = None) -> Self:
|
|
22
|
+
"""Parse native JSON text of this model's kind, rejecting unknown schemas."""
|
|
23
|
+
from .serialization import loads
|
|
24
|
+
result = loads(text, limits=limits)
|
|
25
|
+
if not isinstance(result, cls):
|
|
26
|
+
raise FormatError(f"Expected a serialized {cls.__name__}.")
|
|
27
|
+
return result
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def read_json(cls, path: FilePath, *, limits: Limits | None = None) -> Self:
|
|
31
|
+
from .core import DEFAULT_LIMITS
|
|
32
|
+
selected = DEFAULT_LIMITS if limits is None else limits
|
|
33
|
+
return cls.from_json(read_text(path, selected), limits=selected)
|
|
File without changes
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Immutable presentation documents and safe table-only renderers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import unicodedata
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TYPE_CHECKING, Literal
|
|
9
|
+
|
|
10
|
+
from .core import DEFAULT_LIMITS
|
|
11
|
+
from .errors import SpreadsheetError
|
|
12
|
+
from .serialization import scalar_text
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .table import _Tabular
|
|
16
|
+
|
|
17
|
+
type Alignment = Literal["left", "right", "center"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _newlines(text: str) -> str:
|
|
21
|
+
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _markdown(text: str) -> str:
|
|
25
|
+
text = html.escape(_newlines(text), quote=False)
|
|
26
|
+
return "".join("\\" + character if character in "\\`*_[]|~" else character for character in text).replace("\n", "<br>")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _html(text: str) -> str:
|
|
30
|
+
return html.escape(_newlines(text), quote=True).replace("\n", "<br>")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _terminal(text: str) -> str:
|
|
34
|
+
result = []
|
|
35
|
+
for character in text:
|
|
36
|
+
if character in {"\n", "\r", "\t"}:
|
|
37
|
+
result.append({"\n": r"\n", "\r": r"\r", "\t": r"\t"}[character])
|
|
38
|
+
elif unicodedata.category(character).startswith("C"):
|
|
39
|
+
number = ord(character)
|
|
40
|
+
result.append(f"\\x{number:02x}" if number < 256 else f"\\u{number:04x}")
|
|
41
|
+
else:
|
|
42
|
+
result.append(character)
|
|
43
|
+
return "".join(result)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _width(text: str) -> int:
|
|
47
|
+
return sum(0 if unicodedata.combining(character) else 2 if unicodedata.east_asian_width(character) in {"W", "F"} else 1 for character in text)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _truncate(text: str, maximum: int) -> str:
|
|
51
|
+
if _width(text) <= maximum:
|
|
52
|
+
return text
|
|
53
|
+
result = []
|
|
54
|
+
width = 0
|
|
55
|
+
for character in text:
|
|
56
|
+
size = _width(character)
|
|
57
|
+
if width + size > maximum - 1:
|
|
58
|
+
break
|
|
59
|
+
result.append(character)
|
|
60
|
+
width += size
|
|
61
|
+
return "".join(result) + "…"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class TableDocument:
|
|
66
|
+
"""Display text detached from live cells; this is not a spreadsheet model.
|
|
67
|
+
|
|
68
|
+
Construct from tuples or use ``table.to_document()``. Number formats and
|
|
69
|
+
formulas are not interpreted. Rendering methods return text only.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
headers: tuple[str, ...] = ()
|
|
73
|
+
rows: tuple[tuple[str, ...], ...] = ()
|
|
74
|
+
alignments: tuple[Alignment, ...] = ()
|
|
75
|
+
|
|
76
|
+
def __post_init__(self) -> None:
|
|
77
|
+
if type(self.headers) is not tuple or type(self.rows) is not tuple or type(self.alignments) is not tuple:
|
|
78
|
+
raise SpreadsheetError("A table document must use immutable tuples.")
|
|
79
|
+
if not self.alignments:
|
|
80
|
+
object.__setattr__(self, "alignments", ("left",) * len(self.headers))
|
|
81
|
+
if len(self.alignments) != len(self.headers) or any(value not in {"left", "right", "center"} for value in self.alignments):
|
|
82
|
+
raise SpreadsheetError("Each document column needs a left, right or center alignment.")
|
|
83
|
+
if not all(isinstance(value, str) for value in self.headers):
|
|
84
|
+
raise SpreadsheetError("Document headers must be strings.")
|
|
85
|
+
for row in self.rows:
|
|
86
|
+
if type(row) is not tuple or len(row) != len(self.headers) or not all(isinstance(value, str) for value in row):
|
|
87
|
+
raise SpreadsheetError("Document rows must be tuples of text matching the headers.")
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_table(cls, table: _Tabular, *, formulas: bool = False) -> TableDocument:
|
|
91
|
+
if type(formulas) is not bool:
|
|
92
|
+
raise SpreadsheetError("Formula display must be Boolean.")
|
|
93
|
+
rows = []
|
|
94
|
+
numeric = [False] * len(table.columns)
|
|
95
|
+
other = [False] * len(table.columns)
|
|
96
|
+
for row in table:
|
|
97
|
+
values = []
|
|
98
|
+
for position, name in enumerate(table.columns):
|
|
99
|
+
cell = row.cell(name)
|
|
100
|
+
formula = cell.formula if formulas else None
|
|
101
|
+
values.append(formula if formula is not None else scalar_text(cell.value))
|
|
102
|
+
if formula is not None or (cell.value is not None and cell.data_type not in {"integer", "number", "decimal"}):
|
|
103
|
+
other[position] = True
|
|
104
|
+
elif cell.value is not None:
|
|
105
|
+
numeric[position] = True
|
|
106
|
+
rows.append(tuple(values))
|
|
107
|
+
alignments = tuple("right" if number and not text else "left" for number, text in zip(numeric, other, strict=True))
|
|
108
|
+
return cls(table.columns, tuple(rows), alignments)
|
|
109
|
+
|
|
110
|
+
def to_markdown(self) -> str:
|
|
111
|
+
if not self.headers:
|
|
112
|
+
return ""
|
|
113
|
+
def row(values):
|
|
114
|
+
return "| " + " | ".join(_markdown(value) for value in values) + " |"
|
|
115
|
+
markers = {"left": ":---", "right": "---:", "center": ":---:"}
|
|
116
|
+
lines = [row(self.headers), "| " + " | ".join(markers[value] for value in self.alignments) + " |"]
|
|
117
|
+
lines.extend(row(values) for values in self.rows)
|
|
118
|
+
return "\n".join(lines) + "\n"
|
|
119
|
+
|
|
120
|
+
def to_html(self) -> str:
|
|
121
|
+
if not self.headers:
|
|
122
|
+
return ""
|
|
123
|
+
def row(values, header=False):
|
|
124
|
+
tag = "th" if header else "td"
|
|
125
|
+
scope = ' scope="col"' if header else ""
|
|
126
|
+
cells = [f'<{tag}{scope} style="text-align: {alignment};">{_html(value)}</{tag}>' for value, alignment in zip(values, self.alignments, strict=True)]
|
|
127
|
+
return "<tr>" + "".join(cells) + "</tr>\n"
|
|
128
|
+
return "<table>\n<thead>\n" + row(self.headers, True) + "</thead>\n<tbody>\n" + "".join(row(values) for values in self.rows) + "</tbody>\n</table>\n"
|
|
129
|
+
|
|
130
|
+
def to_ansi(self, *, max_width: int = 32, color: bool = False) -> str:
|
|
131
|
+
"""Render one physical line per row using approximate Unicode cell widths."""
|
|
132
|
+
if type(max_width) is not int or not 1 <= max_width <= DEFAULT_LIMITS.max_input_bytes:
|
|
133
|
+
raise SpreadsheetError("max_width must be a positive integer within the text size limit.")
|
|
134
|
+
if type(color) is not bool:
|
|
135
|
+
raise SpreadsheetError("Terminal color must be Boolean.")
|
|
136
|
+
if not self.headers:
|
|
137
|
+
return ""
|
|
138
|
+
all_rows = [tuple(_terminal(value) for value in row) for row in (self.headers, *self.rows)]
|
|
139
|
+
widths = [min(max_width, max(_width(row[index]) for row in all_rows)) for index in range(len(self.headers))]
|
|
140
|
+
border = "+" + "+".join("-" * (width + 2) for width in widths) + "+"
|
|
141
|
+
|
|
142
|
+
def row(values):
|
|
143
|
+
fields = []
|
|
144
|
+
for text, width, alignment in zip(values, widths, self.alignments, strict=True):
|
|
145
|
+
text = _truncate(text, width)
|
|
146
|
+
extra = width - _width(text)
|
|
147
|
+
left = extra if alignment == "right" else extra // 2 if alignment == "center" else 0
|
|
148
|
+
fields.append(" " * left + text + " " * (extra - left))
|
|
149
|
+
return "| " + " | ".join(fields) + " |"
|
|
150
|
+
|
|
151
|
+
header = row(all_rows[0])
|
|
152
|
+
if color:
|
|
153
|
+
header = "\x1b[1;36m" + header + "\x1b[0m"
|
|
154
|
+
return "\n".join([border, header, border, *(row(values) for values in all_rows[1:]), border]) + "\n"
|
|
155
|
+
|
|
156
|
+
def render(self, format: str, **options) -> str:
|
|
157
|
+
methods = {"markdown": self.to_markdown, "html": self.to_html, "ansi": self.to_ansi}
|
|
158
|
+
if format not in methods:
|
|
159
|
+
raise SpreadsheetError("Expected format='markdown', 'html' or 'ansi'.")
|
|
160
|
+
return methods[format](**options)
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Strict native JSON schema v1 and deliberately text-oriented CSV interchange."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
from datetime import date, datetime
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .core import DEFAULT_LIMITS, Cell, Limits, Scalar, Sheet, Workbook
|
|
13
|
+
from .errors import FormatError, SpreadsheetError
|
|
14
|
+
from .file_io import check_text
|
|
15
|
+
from .table import Table, TableView, _Tabular
|
|
16
|
+
|
|
17
|
+
_FORMAT = "headless-spreadsheets"
|
|
18
|
+
_VERSION = 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def scalar_text(value: Scalar) -> str:
|
|
22
|
+
if value is None:
|
|
23
|
+
return ""
|
|
24
|
+
if isinstance(value, (date, datetime)):
|
|
25
|
+
return value.isoformat()
|
|
26
|
+
return str(value)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _value_data(cell: Cell) -> dict[str, Any]:
|
|
30
|
+
value = cell.value
|
|
31
|
+
if cell.data_type in {"date", "datetime"}:
|
|
32
|
+
value = value.isoformat()
|
|
33
|
+
elif cell.data_type == "decimal":
|
|
34
|
+
value = str(value)
|
|
35
|
+
return {"type": cell.data_type, "value": value}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _cell_data(cell: Cell) -> dict[str, Any]:
|
|
39
|
+
return {
|
|
40
|
+
"value": _value_data(cell), "formula": cell.formula,
|
|
41
|
+
"number_format": cell.number_format, "cache_status": cell.cache_status,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _sheet_data(sheet: Sheet) -> dict[str, Any]:
|
|
46
|
+
return {"name": sheet.name, "width": sheet.shape[1], "rows": [[_cell_data(cell) for cell in row] for row in sheet.rows]}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def dumps(document: Table | Sheet | Workbook, *, indent: int | None = 2) -> str:
|
|
50
|
+
if indent is not None and (type(indent) is not int or not 0 <= indent <= 8):
|
|
51
|
+
raise FormatError("JSON indent must be None or an integer from 0 to 8.")
|
|
52
|
+
if isinstance(document, Table):
|
|
53
|
+
kind = "table"
|
|
54
|
+
data = {"columns": list(document.columns), "rows": [[_cell_data(cell) for cell in row] for row in document._rows]}
|
|
55
|
+
elif isinstance(document, Sheet):
|
|
56
|
+
kind, data = "sheet", _sheet_data(document)
|
|
57
|
+
elif isinstance(document, Workbook):
|
|
58
|
+
# Recheck aggregate bounds before producing a persistent representation.
|
|
59
|
+
Workbook(document, limits=document._limits)
|
|
60
|
+
kind, data = "workbook", {"sheets": [_sheet_data(sheet) for sheet in document]}
|
|
61
|
+
else:
|
|
62
|
+
raise FormatError("Native JSON requires an original Table, Sheet or Workbook.")
|
|
63
|
+
try:
|
|
64
|
+
text = json.dumps(
|
|
65
|
+
{"format": _FORMAT, "schema_version": _VERSION, "kind": kind, "data": data},
|
|
66
|
+
ensure_ascii=False, allow_nan=False, indent=indent,
|
|
67
|
+
) + "\n"
|
|
68
|
+
except (ValueError, OverflowError) as error:
|
|
69
|
+
raise FormatError("A cell cannot be represented in native JSON.") from error
|
|
70
|
+
check_text(text, document._limits)
|
|
71
|
+
return text
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _fields(value: Any, expected: set[str]) -> dict[str, Any]:
|
|
75
|
+
if type(value) is not dict or set(value) != expected:
|
|
76
|
+
raise FormatError(f"Expected exactly these object fields: {', '.join(sorted(expected))}.")
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _decode_value(value: Any) -> Scalar:
|
|
81
|
+
data = _fields(value, {"type", "value"})
|
|
82
|
+
kind, scalar = data["type"], data["value"]
|
|
83
|
+
basic = {"blank": type(None), "boolean": bool, "integer": int, "number": float, "text": str}
|
|
84
|
+
if not isinstance(kind, str):
|
|
85
|
+
raise FormatError("A value type must be a string.")
|
|
86
|
+
if kind in basic and type(scalar) is basic[kind]:
|
|
87
|
+
return scalar
|
|
88
|
+
if type(scalar) is str:
|
|
89
|
+
if kind == "decimal":
|
|
90
|
+
return Decimal(scalar)
|
|
91
|
+
if kind == "date":
|
|
92
|
+
return date.fromisoformat(scalar)
|
|
93
|
+
if kind == "datetime":
|
|
94
|
+
return datetime.fromisoformat(scalar)
|
|
95
|
+
raise FormatError(f"Invalid scalar for native type {kind!r}.")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _decode_cell(value: Any) -> Cell:
|
|
99
|
+
data = _fields(value, {"value", "formula", "number_format", "cache_status"})
|
|
100
|
+
cell = Cell(_decode_value(data["value"]), formula=data["formula"], number_format=data["number_format"])
|
|
101
|
+
if data["cache_status"] != cell.cache_status:
|
|
102
|
+
raise FormatError("A formula cache cannot be marked fresh or contradict its value.")
|
|
103
|
+
return cell
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _decode_rows(rows: Any, width: int, limits: Limits) -> list[list[Cell]]:
|
|
107
|
+
if type(rows) is not list or type(width) is not int or width < 0:
|
|
108
|
+
raise FormatError("Expected rows and a nonnegative integer width.")
|
|
109
|
+
limits.check(len(rows), width)
|
|
110
|
+
result = []
|
|
111
|
+
for row in rows:
|
|
112
|
+
if type(row) is not list or len(row) != width:
|
|
113
|
+
raise FormatError("Native rows must match the declared width.")
|
|
114
|
+
result.append([_decode_cell(cell) for cell in row])
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _decode_sheet(data: Any, limits: Limits) -> Sheet:
|
|
119
|
+
data = _fields(data, {"name", "width", "rows"})
|
|
120
|
+
return Sheet(_decode_rows(data["rows"], data["width"], limits), name=data["name"], width=data["width"], limits=limits)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
124
|
+
result = {}
|
|
125
|
+
for key, value in pairs:
|
|
126
|
+
if key in result:
|
|
127
|
+
raise FormatError(f"Duplicate JSON key: {key!r}")
|
|
128
|
+
result[key] = value
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _nonfinite(value: str) -> None:
|
|
133
|
+
raise FormatError("Native JSON numbers must be finite.")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def loads(text: str, *, limits: Limits | None = None) -> Table | Sheet | Workbook:
|
|
137
|
+
selected = DEFAULT_LIMITS if limits is None else limits
|
|
138
|
+
check_text(text, selected)
|
|
139
|
+
try:
|
|
140
|
+
document = json.loads(text, object_pairs_hook=_unique_object, parse_constant=_nonfinite)
|
|
141
|
+
document = _fields(document, {"format", "schema_version", "kind", "data"})
|
|
142
|
+
if document["format"] != _FORMAT or type(document["schema_version"]) is not int or document["schema_version"] != _VERSION:
|
|
143
|
+
raise FormatError("Unsupported native JSON format or schema version.")
|
|
144
|
+
data = document["data"]
|
|
145
|
+
if document["kind"] == "table":
|
|
146
|
+
data = _fields(data, {"columns", "rows"})
|
|
147
|
+
if type(data["columns"]) is not list:
|
|
148
|
+
raise FormatError("Expected a list of column labels.")
|
|
149
|
+
rows = _decode_rows(data["rows"], len(data["columns"]), selected)
|
|
150
|
+
return Table(data["columns"], rows, limits=selected)
|
|
151
|
+
if document["kind"] == "sheet":
|
|
152
|
+
return _decode_sheet(data, selected)
|
|
153
|
+
if document["kind"] == "workbook":
|
|
154
|
+
data = _fields(data, {"sheets"})
|
|
155
|
+
if type(data["sheets"]) is not list or len(data["sheets"]) > selected.max_sheets:
|
|
156
|
+
raise FormatError("Expected a bounded list of sheets.")
|
|
157
|
+
return Workbook((_decode_sheet(sheet, selected) for sheet in data["sheets"]), limits=selected)
|
|
158
|
+
raise FormatError("Unknown native JSON document kind.")
|
|
159
|
+
except FormatError:
|
|
160
|
+
raise
|
|
161
|
+
except (ValueError, TypeError, ArithmeticError, RecursionError) as error:
|
|
162
|
+
raise FormatError(f"Invalid native JSON: {error}") from error
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _delimiter(delimiter: str) -> None:
|
|
166
|
+
if not isinstance(delimiter, str) or len(delimiter) != 1 or delimiter in '\r\n\0"':
|
|
167
|
+
raise FormatError("A delimiter must be one character other than a newline, NUL or quote.")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _csv_text(text: str, escape_formulas: bool) -> str:
|
|
171
|
+
if escape_formulas and text and (
|
|
172
|
+
text.lstrip().startswith(("=", "+", "-", "@")) or text.startswith(("\t", "\r", "\n"))
|
|
173
|
+
):
|
|
174
|
+
return "'" + text
|
|
175
|
+
return text
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def to_csv(
|
|
179
|
+
table: _Tabular, *, delimiter: str = ",", formulas: bool = False, escape_formulas: bool = True,
|
|
180
|
+
) -> str:
|
|
181
|
+
_delimiter(delimiter)
|
|
182
|
+
if type(formulas) is not bool or type(escape_formulas) is not bool:
|
|
183
|
+
raise FormatError("CSV formula options must be Boolean.")
|
|
184
|
+
if formulas and isinstance(table, TableView):
|
|
185
|
+
raise FormatError("Formula CSV export requires the original table so coordinates stay in place.")
|
|
186
|
+
stream = io.StringIO(newline="")
|
|
187
|
+
writer = csv.writer(stream, delimiter=delimiter, lineterminator="\r\n")
|
|
188
|
+
writer.writerow([_csv_text(name, escape_formulas) for name in table.columns])
|
|
189
|
+
for row in table:
|
|
190
|
+
fields = []
|
|
191
|
+
for name in table.columns:
|
|
192
|
+
cell = row.cell(name)
|
|
193
|
+
if formulas and cell.formula is not None:
|
|
194
|
+
fields.append(cell.formula)
|
|
195
|
+
else:
|
|
196
|
+
value = scalar_text(cell.value)
|
|
197
|
+
fields.append(_csv_text(value, escape_formulas) if type(cell.value) is str else value)
|
|
198
|
+
writer.writerow(fields)
|
|
199
|
+
return stream.getvalue()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def from_csv(
|
|
203
|
+
text: str, *, delimiter: str = ",", formulas: bool = False, limits: Limits | None = None,
|
|
204
|
+
) -> Table:
|
|
205
|
+
selected = DEFAULT_LIMITS if limits is None else limits
|
|
206
|
+
check_text(text, selected)
|
|
207
|
+
_delimiter(delimiter)
|
|
208
|
+
if type(formulas) is not bool:
|
|
209
|
+
raise FormatError("CSV formula interpretation must be Boolean.")
|
|
210
|
+
try:
|
|
211
|
+
reader = csv.reader(io.StringIO(text.removeprefix("\ufeff"), newline=""), delimiter=delimiter, strict=True)
|
|
212
|
+
columns = next(reader, [])
|
|
213
|
+
def rows():
|
|
214
|
+
for row in reader:
|
|
215
|
+
if len(row) != len(columns):
|
|
216
|
+
raise FormatError("CSV rows must match the header width.")
|
|
217
|
+
yield [Cell(formula=value) if formulas and value.startswith("=") else Cell(value) for value in row]
|
|
218
|
+
return Table(columns, rows(), limits=selected)
|
|
219
|
+
except FormatError:
|
|
220
|
+
raise
|
|
221
|
+
except (csv.Error, SpreadsheetError) as error:
|
|
222
|
+
raise FormatError(f"Invalid CSV: {error}") from error
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""Named tables and stable, read-only selections over source cells."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import operator
|
|
6
|
+
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from .core import (
|
|
10
|
+
DEFAULT_LIMITS, Cell, CellInput, Limits, Scalar, Sheet, _collect_rows,
|
|
11
|
+
as_cell, column_name, split_address,
|
|
12
|
+
)
|
|
13
|
+
from .errors import AddressError, ShapeError, SpreadsheetError
|
|
14
|
+
from .file_io import FilePath, read_text, write_text
|
|
15
|
+
from .formats import NativeJSON
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .rendering import TableDocument
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _columns(names: Iterable[str], limits: Limits) -> tuple[str, ...]:
|
|
22
|
+
if isinstance(names, str):
|
|
23
|
+
raise ShapeError("Column names must be an iterable of labels, not one string.")
|
|
24
|
+
result = []
|
|
25
|
+
seen = set()
|
|
26
|
+
for name in names:
|
|
27
|
+
limits.check(0, len(result) + 1)
|
|
28
|
+
if not isinstance(name, str) or not name.strip():
|
|
29
|
+
raise ShapeError("Column names must be nonempty strings.")
|
|
30
|
+
if name in seen:
|
|
31
|
+
raise ShapeError(f"Duplicate column name: {name!r}")
|
|
32
|
+
result.append(name)
|
|
33
|
+
seen.add(name)
|
|
34
|
+
return tuple(result)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Row(Mapping[str, Scalar]):
|
|
38
|
+
"""A read-only mapping with live values and a zero-based source row index."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, table: _Tabular, index: int) -> None:
|
|
41
|
+
self._table = table
|
|
42
|
+
self._index = index
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def index(self) -> int:
|
|
46
|
+
return self._index
|
|
47
|
+
|
|
48
|
+
def cell(self, column: str) -> Cell:
|
|
49
|
+
position = self._table._column_position(column)
|
|
50
|
+
return self._table._root._rows[self._index][position]
|
|
51
|
+
|
|
52
|
+
def __getitem__(self, column: str) -> Scalar:
|
|
53
|
+
return self.cell(column).value
|
|
54
|
+
|
|
55
|
+
def __iter__(self) -> Iterator[str]:
|
|
56
|
+
return iter(self._table.columns)
|
|
57
|
+
|
|
58
|
+
def __len__(self) -> int:
|
|
59
|
+
return len(self._table.columns)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Column(Sequence[Scalar]):
|
|
63
|
+
"""A read-only sequence; ``cells`` exposes immutable metadata snapshots."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, table: _Tabular, name: str) -> None:
|
|
66
|
+
self._table = table
|
|
67
|
+
self._name = name
|
|
68
|
+
self._position = table._column_position(name)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def name(self) -> str:
|
|
72
|
+
return self._name
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def cells(self) -> tuple[Cell, ...]:
|
|
76
|
+
return tuple(self._table._root._rows[row][self._position] for row in self._table._row_positions)
|
|
77
|
+
|
|
78
|
+
def __len__(self) -> int:
|
|
79
|
+
return len(self._table._row_positions)
|
|
80
|
+
|
|
81
|
+
def __getitem__(self, index: int | slice) -> Scalar | tuple[Scalar, ...]:
|
|
82
|
+
if isinstance(index, slice):
|
|
83
|
+
return tuple(cell.value for cell in self.cells[index])
|
|
84
|
+
row = self._table._row_positions[index]
|
|
85
|
+
return self._table._root._rows[row][self._position].value
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_COMPARISONS = {
|
|
89
|
+
"eq": operator.eq, "ne": operator.ne, "gt": operator.gt, "gte": operator.ge,
|
|
90
|
+
"lt": operator.lt, "lte": operator.le,
|
|
91
|
+
}
|
|
92
|
+
_OPERATORS = {*_COMPARISONS, "in", "contains", "is_none"}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _Tabular:
|
|
96
|
+
@property
|
|
97
|
+
def _root(self) -> Table:
|
|
98
|
+
raise NotImplementedError
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def _row_positions(self) -> Sequence[int]:
|
|
102
|
+
raise NotImplementedError
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def _column_positions(self) -> Sequence[int]:
|
|
106
|
+
raise NotImplementedError
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def columns(self) -> tuple[str, ...]:
|
|
110
|
+
return tuple(self._root._columns[column] for column in self._column_positions)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def shape(self) -> tuple[int, int]:
|
|
114
|
+
return len(self._row_positions), len(self._column_positions)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def rows(self) -> tuple[Row, ...]:
|
|
118
|
+
return tuple(self)
|
|
119
|
+
|
|
120
|
+
def __len__(self) -> int:
|
|
121
|
+
return len(self._row_positions)
|
|
122
|
+
|
|
123
|
+
def __iter__(self) -> Iterator[Row]:
|
|
124
|
+
return (Row(self, index) for index in self._row_positions)
|
|
125
|
+
|
|
126
|
+
def __getitem__(self, name: str) -> Column:
|
|
127
|
+
return Column(self, name)
|
|
128
|
+
|
|
129
|
+
def _column_position(self, name: str) -> int:
|
|
130
|
+
try:
|
|
131
|
+
return self._column_lookup[name]
|
|
132
|
+
except KeyError:
|
|
133
|
+
raise KeyError(f"Unknown column: {name!r}") from None
|
|
134
|
+
|
|
135
|
+
def _position(self, address: str) -> tuple[int, int]:
|
|
136
|
+
row, column = split_address(address)
|
|
137
|
+
if row > len(self) or column >= len(self.columns):
|
|
138
|
+
raise AddressError(f"Address {address!r} is outside the table (including its header).")
|
|
139
|
+
return row, column
|
|
140
|
+
|
|
141
|
+
def cell(self, address: str) -> Cell:
|
|
142
|
+
"""Read a cell; header row 1 is followed by data rows starting at 2."""
|
|
143
|
+
row, column = self._position(address)
|
|
144
|
+
if row == 0:
|
|
145
|
+
return Cell(self.columns[column])
|
|
146
|
+
return self._root._rows[self._row_positions[row - 1]][self._column_positions[column]]
|
|
147
|
+
|
|
148
|
+
def source_address(self, address: str) -> str:
|
|
149
|
+
"""Map a local view address to the original table, including its header."""
|
|
150
|
+
row, column = self._position(address)
|
|
151
|
+
original_row = 1 if row == 0 else self._row_positions[row - 1] + 2
|
|
152
|
+
return f"{column_name(self._column_positions[column])}{original_row}"
|
|
153
|
+
|
|
154
|
+
def to_records(self) -> list[dict[str, Scalar]]:
|
|
155
|
+
return [dict(row) for row in self]
|
|
156
|
+
|
|
157
|
+
def to_csv(
|
|
158
|
+
self, path: FilePath | None = None, *, delimiter: str = ",", formulas: bool = False,
|
|
159
|
+
escape_formulas: bool = True,
|
|
160
|
+
) -> str:
|
|
161
|
+
"""Export values, escaping spreadsheet-active text unless explicitly disabled."""
|
|
162
|
+
from .serialization import to_csv
|
|
163
|
+
return write_text(to_csv(self, delimiter=delimiter, formulas=formulas, escape_formulas=escape_formulas), path)
|
|
164
|
+
|
|
165
|
+
def to_document(self, *, formulas: bool = False) -> TableDocument:
|
|
166
|
+
"""Capture display text independently of future source edits."""
|
|
167
|
+
from .rendering import TableDocument
|
|
168
|
+
return TableDocument.from_table(self, formulas=formulas)
|
|
169
|
+
|
|
170
|
+
def render(self, format: str, *, formulas: bool = False, **options) -> str:
|
|
171
|
+
return self.to_document(formulas=formulas).render(format, **options)
|
|
172
|
+
|
|
173
|
+
def to_markdown(self, path: FilePath | None = None, *, formulas: bool = False) -> str:
|
|
174
|
+
return write_text(self.render("markdown", formulas=formulas), path)
|
|
175
|
+
|
|
176
|
+
def to_html(self, path: FilePath | None = None, *, formulas: bool = False) -> str:
|
|
177
|
+
return write_text(self.render("html", formulas=formulas), path)
|
|
178
|
+
|
|
179
|
+
def to_ansi(
|
|
180
|
+
self, path: FilePath | None = None, *, formulas: bool = False,
|
|
181
|
+
max_width: int = 32, color: bool = False,
|
|
182
|
+
) -> str:
|
|
183
|
+
return write_text(self.render("ansi", formulas=formulas, max_width=max_width, color=color), path)
|
|
184
|
+
|
|
185
|
+
def select(self, *columns: str) -> TableView:
|
|
186
|
+
names = _columns(columns, self._root._limits)
|
|
187
|
+
positions = tuple(self._column_position(name) for name in names)
|
|
188
|
+
return TableView(self._root, self._row_positions, positions)
|
|
189
|
+
|
|
190
|
+
def where(self, predicate: Callable[[Row], bool] | None = None, **conditions: Any) -> TableView:
|
|
191
|
+
"""Combine a Python predicate and named conditions using AND."""
|
|
192
|
+
if predicate is not None and not callable(predicate):
|
|
193
|
+
raise TypeError("A row predicate must be callable.")
|
|
194
|
+
prepared = []
|
|
195
|
+
for key, expected in conditions.items():
|
|
196
|
+
if key in self.columns:
|
|
197
|
+
name, operation = key, "eq"
|
|
198
|
+
else:
|
|
199
|
+
name, separator, operation = key.rpartition("__")
|
|
200
|
+
if not separator:
|
|
201
|
+
name, operation = key, "eq"
|
|
202
|
+
if operation not in _OPERATORS:
|
|
203
|
+
raise SpreadsheetError(f"Unknown filter operator: {operation!r}")
|
|
204
|
+
position = self._column_position(name)
|
|
205
|
+
if operation == "is_none" and type(expected) is not bool:
|
|
206
|
+
raise SpreadsheetError("is_none expects True or False.")
|
|
207
|
+
# Membership uses a stable reusable collection, not a consumed generator.
|
|
208
|
+
if operation == "in" and not isinstance(expected, (Sequence, set, frozenset, Mapping)):
|
|
209
|
+
raise SpreadsheetError("in expects a reusable sequence, set or mapping.")
|
|
210
|
+
prepared.append((position, operation, expected))
|
|
211
|
+
|
|
212
|
+
def matches(index: int) -> bool:
|
|
213
|
+
for position, operation, expected in prepared:
|
|
214
|
+
value = self._root._rows[index][position].value
|
|
215
|
+
try:
|
|
216
|
+
if operation == "is_none":
|
|
217
|
+
match = (value is None) == expected
|
|
218
|
+
elif operation == "in":
|
|
219
|
+
match = value in expected
|
|
220
|
+
elif operation == "contains":
|
|
221
|
+
match = value is not None and expected in value
|
|
222
|
+
elif operation in {"gt", "gte", "lt", "lte"} and (value is None or expected is None):
|
|
223
|
+
match = False
|
|
224
|
+
else:
|
|
225
|
+
match = _COMPARISONS[operation](value, expected)
|
|
226
|
+
except (TypeError, ValueError) as error:
|
|
227
|
+
raise SpreadsheetError(f"Cannot apply {operation!r} to column {self._root._columns[position]!r}.") from error
|
|
228
|
+
if not match:
|
|
229
|
+
return False
|
|
230
|
+
return predicate is None or bool(predicate(Row(self, index)))
|
|
231
|
+
|
|
232
|
+
return TableView(self._root, (index for index in self._row_positions if matches(index)), self._column_positions)
|
|
233
|
+
|
|
234
|
+
def sort_by(self, *columns: str, reverse: bool = False, nulls: str = "last") -> TableView:
|
|
235
|
+
"""Stable multi-column ordering; blank placement is independent of reverse."""
|
|
236
|
+
if not columns:
|
|
237
|
+
raise SpreadsheetError("sort_by requires at least one column.")
|
|
238
|
+
if type(reverse) is not bool or nulls not in {"first", "last"}:
|
|
239
|
+
raise SpreadsheetError("Expected a Boolean reverse and nulls='first' or 'last'.")
|
|
240
|
+
positions = [self._column_position(name) for name in columns]
|
|
241
|
+
indices = list(self._row_positions)
|
|
242
|
+
for position in reversed(positions):
|
|
243
|
+
present, missing = [], []
|
|
244
|
+
for index in indices:
|
|
245
|
+
(missing if self._root._rows[index][position].value is None else present).append(index)
|
|
246
|
+
try:
|
|
247
|
+
present.sort(key=lambda index: self._root._rows[index][position].value, reverse=reverse)
|
|
248
|
+
except TypeError as error:
|
|
249
|
+
raise SpreadsheetError(f"Column {self._root._columns[position]!r} contains incomparable values.") from error
|
|
250
|
+
indices = missing + present if nulls == "first" else present + missing
|
|
251
|
+
return TableView(self._root, indices, self._column_positions)
|
|
252
|
+
|
|
253
|
+
def to_table(self) -> Table:
|
|
254
|
+
"""Materialize displayed values, dropping formulas rather than relocating them."""
|
|
255
|
+
return Table(self.columns, ([row[name] for name in self.columns] for row in self), limits=self._root._limits)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class Table(_Tabular, NativeJSON):
|
|
259
|
+
"""A standalone named table with explicit edits and immutable headers."""
|
|
260
|
+
|
|
261
|
+
def __init__(
|
|
262
|
+
self, columns: Iterable[str] = (), rows: Iterable[Iterable[CellInput]] = (),
|
|
263
|
+
*, limits: Limits = DEFAULT_LIMITS,
|
|
264
|
+
) -> None:
|
|
265
|
+
self._limits = limits
|
|
266
|
+
self._columns = _columns(columns, limits)
|
|
267
|
+
self._column_lookup = {name: index for index, name in enumerate(self._columns)}
|
|
268
|
+
self._rows, _ = _collect_rows(rows, width=len(self._columns), limits=limits)
|
|
269
|
+
|
|
270
|
+
@classmethod
|
|
271
|
+
def from_records(
|
|
272
|
+
cls, records: Iterable[Mapping[str, CellInput]], *, columns: Iterable[str] | None = None,
|
|
273
|
+
limits: Limits = DEFAULT_LIMITS,
|
|
274
|
+
) -> Table:
|
|
275
|
+
explicit = columns is not None
|
|
276
|
+
names = list(_columns(columns, limits)) if explicit else []
|
|
277
|
+
known = set(names)
|
|
278
|
+
collected = []
|
|
279
|
+
for record in records:
|
|
280
|
+
limits.check(len(collected) + 1, len(names))
|
|
281
|
+
if not isinstance(record, Mapping):
|
|
282
|
+
raise ShapeError("Each record must be a mapping of column names to cells.")
|
|
283
|
+
current = {}
|
|
284
|
+
for name, value in record.items():
|
|
285
|
+
if not isinstance(name, str) or not name.strip():
|
|
286
|
+
raise ShapeError("Record keys must be nonempty column names.")
|
|
287
|
+
if name not in known:
|
|
288
|
+
if explicit:
|
|
289
|
+
raise ShapeError(f"Record contains undeclared column: {name!r}")
|
|
290
|
+
limits.check(len(collected) + 1, len(names) + 1)
|
|
291
|
+
names.append(name)
|
|
292
|
+
known.add(name)
|
|
293
|
+
current[name] = as_cell(value)
|
|
294
|
+
collected.append(current)
|
|
295
|
+
return cls(names, ([record.get(name) for name in names] for record in collected), limits=limits)
|
|
296
|
+
|
|
297
|
+
@classmethod
|
|
298
|
+
def from_csv(
|
|
299
|
+
cls, text: str, *, delimiter: str = ",", formulas: bool = False,
|
|
300
|
+
limits: Limits | None = None,
|
|
301
|
+
) -> Table:
|
|
302
|
+
"""Parse CSV text; fields remain literal strings unless formula mode is explicit."""
|
|
303
|
+
from .serialization import from_csv
|
|
304
|
+
return from_csv(text, delimiter=delimiter, formulas=formulas, limits=limits)
|
|
305
|
+
|
|
306
|
+
@classmethod
|
|
307
|
+
def read_csv(
|
|
308
|
+
cls, path: FilePath, *, delimiter: str = ",", formulas: bool = False,
|
|
309
|
+
limits: Limits | None = None,
|
|
310
|
+
) -> Table:
|
|
311
|
+
selected = DEFAULT_LIMITS if limits is None else limits
|
|
312
|
+
return cls.from_csv(read_text(path, selected), delimiter=delimiter, formulas=formulas, limits=selected)
|
|
313
|
+
|
|
314
|
+
@property
|
|
315
|
+
def _root(self) -> Table:
|
|
316
|
+
return self
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def columns(self) -> tuple[str, ...]:
|
|
320
|
+
return self._columns
|
|
321
|
+
|
|
322
|
+
@property
|
|
323
|
+
def _row_positions(self) -> range:
|
|
324
|
+
return range(len(self._rows))
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def _column_positions(self) -> range:
|
|
328
|
+
return range(len(self._columns))
|
|
329
|
+
|
|
330
|
+
def set(self, address: str, value: CellInput) -> None:
|
|
331
|
+
row, column = self._position(address)
|
|
332
|
+
if row == 0:
|
|
333
|
+
raise AddressError("Table headers cannot be edited as cells.")
|
|
334
|
+
cell = as_cell(value)
|
|
335
|
+
index = row - 1
|
|
336
|
+
changed = (*self._rows[index][:column], cell, *self._rows[index][column + 1:])
|
|
337
|
+
self._rows = (*self._rows[:index], changed, *self._rows[index + 1:])
|
|
338
|
+
|
|
339
|
+
def set_formula(self, address: str, formula: str, *, cached_value: Scalar = None) -> None:
|
|
340
|
+
self.set(address, Cell(cached_value, formula=formula))
|
|
341
|
+
|
|
342
|
+
def add_column(
|
|
343
|
+
self, name: str, values: Iterable[CellInput] | Callable[[Row], CellInput] | None = None,
|
|
344
|
+
) -> None:
|
|
345
|
+
"""Append blanks, an iterable or per-row results after validating every value."""
|
|
346
|
+
names = _columns((*self._columns, name), self._limits)
|
|
347
|
+
self._limits.check(len(self), len(names))
|
|
348
|
+
if values is None:
|
|
349
|
+
cells = [Cell() for _ in self._rows]
|
|
350
|
+
elif callable(values):
|
|
351
|
+
cells = [as_cell(values(row)) for row in self]
|
|
352
|
+
else:
|
|
353
|
+
if isinstance(values, (str, bytes)):
|
|
354
|
+
raise ShapeError("Column values must be a sequence of cells, not text.")
|
|
355
|
+
cells = []
|
|
356
|
+
for value in values:
|
|
357
|
+
if len(cells) >= len(self):
|
|
358
|
+
raise ShapeError("A new column needs exactly one value per row.")
|
|
359
|
+
cells.append(as_cell(value))
|
|
360
|
+
if len(cells) != len(self):
|
|
361
|
+
raise ShapeError("A new column needs exactly one value per row.")
|
|
362
|
+
changed = tuple((*row, cell) for row, cell in zip(self._rows, cells, strict=True))
|
|
363
|
+
self._rows, self._columns = changed, names
|
|
364
|
+
self._column_lookup = {name: index for index, name in enumerate(names)}
|
|
365
|
+
|
|
366
|
+
def to_sheet(self, *, name: str = "Sheet1") -> Sheet:
|
|
367
|
+
return Sheet((self._columns, *self._rows), name=name, width=len(self.columns), limits=self._limits)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class TableView(_Tabular):
|
|
371
|
+
"""Fixed source membership with live values; selections never move formulas.
|
|
372
|
+
|
|
373
|
+
Create views with ``where``, ``select`` and ``sort_by``. Materialize using
|
|
374
|
+
``to_table`` when independent values are required.
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
def __init__(self, source: Table, rows: Iterable[int], columns: Iterable[int]) -> None:
|
|
378
|
+
if not isinstance(source, Table):
|
|
379
|
+
raise ShapeError("A view requires a source Table.")
|
|
380
|
+
|
|
381
|
+
def positions(values, size, maximum):
|
|
382
|
+
result = []
|
|
383
|
+
for value in values:
|
|
384
|
+
if len(result) >= maximum:
|
|
385
|
+
raise ShapeError("View dimensions exceed the configured limits.")
|
|
386
|
+
if type(value) is not int or not 0 <= value < size:
|
|
387
|
+
raise ShapeError("View positions must be valid zero-based source indices.")
|
|
388
|
+
result.append(value)
|
|
389
|
+
return tuple(result)
|
|
390
|
+
|
|
391
|
+
self._source = source
|
|
392
|
+
self._selected_rows = positions(rows, len(source), source._limits.max_rows)
|
|
393
|
+
self._selected_columns = positions(columns, len(source.columns), source._limits.max_columns)
|
|
394
|
+
source._limits.check(len(self._selected_rows), len(self._selected_columns))
|
|
395
|
+
if len(set(self._selected_columns)) != len(self._selected_columns):
|
|
396
|
+
raise ShapeError("View columns must be unique.")
|
|
397
|
+
self._names = tuple(source.columns[index] for index in self._selected_columns)
|
|
398
|
+
self._column_lookup = dict(zip(self._names, self._selected_columns, strict=True))
|
|
399
|
+
|
|
400
|
+
@property
|
|
401
|
+
def columns(self) -> tuple[str, ...]:
|
|
402
|
+
return self._names
|
|
403
|
+
|
|
404
|
+
@property
|
|
405
|
+
def _root(self) -> Table:
|
|
406
|
+
return self._source
|
|
407
|
+
|
|
408
|
+
@property
|
|
409
|
+
def _row_positions(self) -> tuple[int, ...]:
|
|
410
|
+
return self._selected_rows
|
|
411
|
+
|
|
412
|
+
@property
|
|
413
|
+
def _column_positions(self) -> tuple[int, ...]:
|
|
414
|
+
return self._selected_columns
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: headless-spreadsheets
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Spreadsheet-native tables with typed cells and portable, dependency-free interchange.
|
|
5
|
+
Author: ellainng
|
|
6
|
+
Author-email: ellainng <299958882+ellainng@users.noreply.github.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Classifier: Topic :: Office/Business :: Financial :: Spreadsheet
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.14
|
|
16
|
+
Project-URL: Source, https://github.com/outside-labs/headless_spreadsheets
|
|
17
|
+
Project-URL: Issues, https://github.com/outside-labs/headless_spreadsheets/issues
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# Headless Spreadsheets
|
|
21
|
+
|
|
22
|
+
Spreadsheet-native tables for ordinary Python applications. Pure Python 3.14+,
|
|
23
|
+
zero runtime dependencies, MIT licensed.
|
|
24
|
+
|
|
25
|
+
Keep typed values, formula text and cached results together, work with named
|
|
26
|
+
records, and move data between Python, CSV, native JSON and table displays.
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pip install headless-spreadsheets
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## A table without a workbook
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from headless_spreadsheets import Table
|
|
36
|
+
|
|
37
|
+
sales = Table.from_records([
|
|
38
|
+
{"item": "Book", "price": 12, "quantity": 3},
|
|
39
|
+
{"item": "Pen", "price": 2, "quantity": 10},
|
|
40
|
+
{"item": "Bag", "price": 25, "quantity": 2},
|
|
41
|
+
])
|
|
42
|
+
sales.add_column("total", lambda row: row["price"] * row["quantity"])
|
|
43
|
+
|
|
44
|
+
report = sales.where(total__gte=30).sort_by("total", reverse=True)
|
|
45
|
+
print(report.select("item", "total").to_markdown())
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```markdown
|
|
49
|
+
| item | total |
|
|
50
|
+
| :--- | ---: |
|
|
51
|
+
| Bag | 50 |
|
|
52
|
+
| Book | 36 |
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Rows behave like read-only dictionaries; columns behave like read-only
|
|
56
|
+
sequences. Filtering, sorting and selection return views with fixed source
|
|
57
|
+
membership and live values. They keep the original cells in place.
|
|
58
|
+
|
|
59
|
+
## Preserve spreadsheet expressions
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
sales.set_formula("D2", "=B2*C2", cached_value=36)
|
|
63
|
+
cell = sales.cell("D2")
|
|
64
|
+
assert cell.formula == "=B2*C2"
|
|
65
|
+
assert cell.value == 36
|
|
66
|
+
assert cell.cache_status == "stale"
|
|
67
|
+
|
|
68
|
+
restored = Table.from_json(sales.to_json())
|
|
69
|
+
assert restored.cell("D2") == cell
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Table addresses include a header at row 1; data begins at row 2. `Sheet` is a
|
|
73
|
+
rectangular grid without an implicit header, and `Workbook` contains named
|
|
74
|
+
sheets. Literal strings starting with `=` remain text unless explicitly supplied
|
|
75
|
+
as formulas.
|
|
76
|
+
|
|
77
|
+
**v0.1 preserves formulas and supplied cached results; it does not calculate
|
|
78
|
+
them.** Formula caches are always `stale` or `missing`. Unknown formulas stay
|
|
79
|
+
inspectable and exportable. Views expose `source_address()` and retain original
|
|
80
|
+
formula text; `view.to_table()` deliberately creates an independent values-only
|
|
81
|
+
copy. Number-format hints are stored without being applied.
|
|
82
|
+
|
|
83
|
+
## Interchange and display
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
sales.to_json("sales.json") # Types, formulas, cache and hints
|
|
87
|
+
sales = Table.read_json("sales.json")
|
|
88
|
+
sales.to_csv("sales.csv") # Values; active text escaped by default
|
|
89
|
+
text_table = Table.read_csv("sales.csv") # All imported fields are text
|
|
90
|
+
sales.to_csv("sales.tsv", delimiter="\t")
|
|
91
|
+
|
|
92
|
+
sales.to_markdown("sales.md")
|
|
93
|
+
sales.to_html("sales.html")
|
|
94
|
+
print(sales.to_ansi(max_width=24, color=True))
|
|
95
|
+
print(sales.render("markdown", formulas=True))
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Each export returns text and optionally writes a UTF-8 file atomically. Text
|
|
99
|
+
readers are `from_csv(text)` and `from_json(text)`; path readers are `read_csv`
|
|
100
|
+
and `read_json`. CSV loses Python types and blank-versus-empty distinctions.
|
|
101
|
+
Explicit CSV formula modes are available. Native JSON is the fidelity format.
|
|
102
|
+
HTML/Markdown escape content; terminal output neutralizes input controls.
|
|
103
|
+
|
|
104
|
+
## Scope
|
|
105
|
+
|
|
106
|
+
Supported cell values are blank (`None`), Boolean, integer, finite float, text,
|
|
107
|
+
`Decimal`, `date` and `datetime`. Configurable `Limits` bound rows, columns,
|
|
108
|
+
cells, sheets and serialized input. The core has no custom C/Rust extensions and
|
|
109
|
+
requires no third-party packages. It makes no pandas-scale performance claim.
|
|
110
|
+
|
|
111
|
+
Formula calculation, `F` expressions, XLSX, Google Sheets, Markdown/HTML import,
|
|
112
|
+
grouping/joins, async execution and undo/history are later milestones. The native
|
|
113
|
+
table writer has no Pretty Notebook dependency; shared typed Markdown extraction
|
|
114
|
+
is tracked separately.
|
|
115
|
+
|
|
116
|
+
- [API and behavior](https://github.com/outside-labs/headless_spreadsheets/blob/main/docs/api.md)
|
|
117
|
+
- [Interchange contracts](https://github.com/outside-labs/headless_spreadsheets/blob/main/docs/formats.md)
|
|
118
|
+
- [Presentation and Unicode limits](https://github.com/outside-labs/headless_spreadsheets/blob/main/docs/presentation.md)
|
|
119
|
+
- [Implementation plan](https://github.com/outside-labs/headless_spreadsheets/blob/main/IMPLEMENTATION_PLAN.md)
|
|
120
|
+
- [Project and queue](https://github.com/orgs/outside-labs/projects/16)
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
uv sync --locked
|
|
126
|
+
uv run --frozen python -W error::ResourceWarning -m unittest discover -s tests -v
|
|
127
|
+
uv run --frozen python examples/sales.py
|
|
128
|
+
uv run --frozen python scripts/check_distribution.py
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
CI tests Python 3.14 on Linux, macOS and Windows. Distribution verification
|
|
132
|
+
checks exact archive contents, metadata, source bytes, source rebuild parity and
|
|
133
|
+
the complete suite in independent wheel/source installations. See
|
|
134
|
+
[release procedures](https://github.com/outside-labs/headless_spreadsheets/blob/main/docs/releases.md)
|
|
135
|
+
for the tag-driven Trusted Publishing workflow.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
headless_spreadsheets/__init__.py,sha256=tFO-HQvWALirRtBn0EWh9m_gxv9eJCACwnjDvFYREYo,623
|
|
2
|
+
headless_spreadsheets/core.py,sha256=J09404BykiTIOZJ5NRg1CvEYiP4_LxV7GDwLU5pDBLI,8416
|
|
3
|
+
headless_spreadsheets/errors.py,sha256=24dmUq5_0p4xfqjhDO16Z7eg9RVMTlJpR37hsnyCc2k,491
|
|
4
|
+
headless_spreadsheets/file_io.py,sha256=MsSFkjvDaj9puPplNN3ppnm8gu-ZCRXoew_vfjNFFQw,1993
|
|
5
|
+
headless_spreadsheets/formats.py,sha256=ai-x0ve1ctGu7YhPeTttbfXcEOxhFLEGA1Gaj2qK26g,1249
|
|
6
|
+
headless_spreadsheets/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
headless_spreadsheets/rendering.py,sha256=p-qqIWTA1Ez0e6uUwQ_D0icvlY1vK6sbkqMuUFwG5as,7168
|
|
8
|
+
headless_spreadsheets/serialization.py,sha256=cJmGr9IoPXImvHUcS385aAM5rDut7xvYImk4bAM5O70,9243
|
|
9
|
+
headless_spreadsheets/table.py,sha256=airpeAdHfWTsdSjJo7WVBbCN_AB0_7nnlN3IsdVwBiA,17496
|
|
10
|
+
headless_spreadsheets-0.1.0.dist-info/licenses/LICENSE,sha256=MJiaArfnOql8NJRA2TI9JIYDOoiis3Rh8xQruzBRXoU,1066
|
|
11
|
+
headless_spreadsheets-0.1.0.dist-info/WHEEL,sha256=e4_1dyBeezi8ZjfxrZ3bnVOxFDa3ksqVqH0jTHkUZ3k,81
|
|
12
|
+
headless_spreadsheets-0.1.0.dist-info/METADATA,sha256=PL9ovVrAc2XEd1uealNkVM1oAGSZsEbD_SA1OcalHAo,5410
|
|
13
|
+
headless_spreadsheets-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ella Inng
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|