headless-spreadsheets 0.1.0__tar.gz

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,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.
@@ -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,116 @@
1
+ # Headless Spreadsheets
2
+
3
+ Spreadsheet-native tables for ordinary Python applications. Pure Python 3.14+,
4
+ zero runtime dependencies, MIT licensed.
5
+
6
+ Keep typed values, formula text and cached results together, work with named
7
+ records, and move data between Python, CSV, native JSON and table displays.
8
+
9
+ ```sh
10
+ pip install headless-spreadsheets
11
+ ```
12
+
13
+ ## A table without a workbook
14
+
15
+ ```python
16
+ from headless_spreadsheets import Table
17
+
18
+ sales = Table.from_records([
19
+ {"item": "Book", "price": 12, "quantity": 3},
20
+ {"item": "Pen", "price": 2, "quantity": 10},
21
+ {"item": "Bag", "price": 25, "quantity": 2},
22
+ ])
23
+ sales.add_column("total", lambda row: row["price"] * row["quantity"])
24
+
25
+ report = sales.where(total__gte=30).sort_by("total", reverse=True)
26
+ print(report.select("item", "total").to_markdown())
27
+ ```
28
+
29
+ ```markdown
30
+ | item | total |
31
+ | :--- | ---: |
32
+ | Bag | 50 |
33
+ | Book | 36 |
34
+ ```
35
+
36
+ Rows behave like read-only dictionaries; columns behave like read-only
37
+ sequences. Filtering, sorting and selection return views with fixed source
38
+ membership and live values. They keep the original cells in place.
39
+
40
+ ## Preserve spreadsheet expressions
41
+
42
+ ```python
43
+ sales.set_formula("D2", "=B2*C2", cached_value=36)
44
+ cell = sales.cell("D2")
45
+ assert cell.formula == "=B2*C2"
46
+ assert cell.value == 36
47
+ assert cell.cache_status == "stale"
48
+
49
+ restored = Table.from_json(sales.to_json())
50
+ assert restored.cell("D2") == cell
51
+ ```
52
+
53
+ Table addresses include a header at row 1; data begins at row 2. `Sheet` is a
54
+ rectangular grid without an implicit header, and `Workbook` contains named
55
+ sheets. Literal strings starting with `=` remain text unless explicitly supplied
56
+ as formulas.
57
+
58
+ **v0.1 preserves formulas and supplied cached results; it does not calculate
59
+ them.** Formula caches are always `stale` or `missing`. Unknown formulas stay
60
+ inspectable and exportable. Views expose `source_address()` and retain original
61
+ formula text; `view.to_table()` deliberately creates an independent values-only
62
+ copy. Number-format hints are stored without being applied.
63
+
64
+ ## Interchange and display
65
+
66
+ ```python
67
+ sales.to_json("sales.json") # Types, formulas, cache and hints
68
+ sales = Table.read_json("sales.json")
69
+ sales.to_csv("sales.csv") # Values; active text escaped by default
70
+ text_table = Table.read_csv("sales.csv") # All imported fields are text
71
+ sales.to_csv("sales.tsv", delimiter="\t")
72
+
73
+ sales.to_markdown("sales.md")
74
+ sales.to_html("sales.html")
75
+ print(sales.to_ansi(max_width=24, color=True))
76
+ print(sales.render("markdown", formulas=True))
77
+ ```
78
+
79
+ Each export returns text and optionally writes a UTF-8 file atomically. Text
80
+ readers are `from_csv(text)` and `from_json(text)`; path readers are `read_csv`
81
+ and `read_json`. CSV loses Python types and blank-versus-empty distinctions.
82
+ Explicit CSV formula modes are available. Native JSON is the fidelity format.
83
+ HTML/Markdown escape content; terminal output neutralizes input controls.
84
+
85
+ ## Scope
86
+
87
+ Supported cell values are blank (`None`), Boolean, integer, finite float, text,
88
+ `Decimal`, `date` and `datetime`. Configurable `Limits` bound rows, columns,
89
+ cells, sheets and serialized input. The core has no custom C/Rust extensions and
90
+ requires no third-party packages. It makes no pandas-scale performance claim.
91
+
92
+ Formula calculation, `F` expressions, XLSX, Google Sheets, Markdown/HTML import,
93
+ grouping/joins, async execution and undo/history are later milestones. The native
94
+ table writer has no Pretty Notebook dependency; shared typed Markdown extraction
95
+ is tracked separately.
96
+
97
+ - [API and behavior](https://github.com/outside-labs/headless_spreadsheets/blob/main/docs/api.md)
98
+ - [Interchange contracts](https://github.com/outside-labs/headless_spreadsheets/blob/main/docs/formats.md)
99
+ - [Presentation and Unicode limits](https://github.com/outside-labs/headless_spreadsheets/blob/main/docs/presentation.md)
100
+ - [Implementation plan](https://github.com/outside-labs/headless_spreadsheets/blob/main/IMPLEMENTATION_PLAN.md)
101
+ - [Project and queue](https://github.com/orgs/outside-labs/projects/16)
102
+
103
+ ## Development
104
+
105
+ ```sh
106
+ uv sync --locked
107
+ uv run --frozen python -W error::ResourceWarning -m unittest discover -s tests -v
108
+ uv run --frozen python examples/sales.py
109
+ uv run --frozen python scripts/check_distribution.py
110
+ ```
111
+
112
+ CI tests Python 3.14 on Linux, macOS and Windows. Distribution verification
113
+ checks exact archive contents, metadata, source bytes, source rebuild parity and
114
+ the complete suite in independent wheel/source installations. See
115
+ [release procedures](https://github.com/outside-labs/headless_spreadsheets/blob/main/docs/releases.md)
116
+ for the tag-driven Trusted Publishing workflow.
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "headless-spreadsheets"
3
+ version = "0.1.0"
4
+ description = "Spreadsheet-native tables with typed cells and portable, dependency-free interchange."
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = []
8
+ license = "MIT"
9
+ license-files = ["LICENSE"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "Programming Language :: Python :: 3 :: Only",
14
+ "Programming Language :: Python :: 3.14",
15
+ "Topic :: Office/Business :: Financial :: Spreadsheet",
16
+ "Typing :: Typed",
17
+ ]
18
+
19
+ [[project.authors]]
20
+ name = "ellainng"
21
+ email = "299958882+ellainng@users.noreply.github.com"
22
+
23
+ [project.urls]
24
+ Source = "https://github.com/outside-labs/headless_spreadsheets"
25
+ Issues = "https://github.com/outside-labs/headless_spreadsheets/issues"
26
+
27
+ [build-system]
28
+ requires = ["uv_build>=0.12.19,<0.13.0"]
29
+ build-backend = "uv_build"
@@ -0,0 +1,28 @@
1
+ [project]
2
+ name = "headless-spreadsheets"
3
+ version = "0.1.0"
4
+ description = "Spreadsheet-native tables with typed cells and portable, dependency-free interchange."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "ellainng", email = "299958882+ellainng@users.noreply.github.com" }
8
+ ]
9
+ requires-python = ">=3.14"
10
+ dependencies = []
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "Programming Language :: Python :: 3.14",
18
+ "Topic :: Office/Business :: Financial :: Spreadsheet",
19
+ "Typing :: Typed",
20
+ ]
21
+
22
+ [project.urls]
23
+ Source = "https://github.com/outside-labs/headless_spreadsheets"
24
+ Issues = "https://github.com/outside-labs/headless_spreadsheets/issues"
25
+
26
+ [build-system]
27
+ requires = ["uv_build>=0.12.19,<0.13.0"]
28
+ build-backend = "uv_build"
@@ -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