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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.19
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.