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.
- headless_spreadsheets-0.1.0/LICENSE +21 -0
- headless_spreadsheets-0.1.0/PKG-INFO +135 -0
- headless_spreadsheets-0.1.0/README.md +116 -0
- headless_spreadsheets-0.1.0/pyproject.toml +29 -0
- headless_spreadsheets-0.1.0/pyproject.toml.orig +28 -0
- headless_spreadsheets-0.1.0/src/headless_spreadsheets/__init__.py +14 -0
- headless_spreadsheets-0.1.0/src/headless_spreadsheets/core.py +225 -0
- headless_spreadsheets-0.1.0/src/headless_spreadsheets/errors.py +17 -0
- headless_spreadsheets-0.1.0/src/headless_spreadsheets/file_io.py +60 -0
- headless_spreadsheets-0.1.0/src/headless_spreadsheets/formats.py +33 -0
- headless_spreadsheets-0.1.0/src/headless_spreadsheets/py.typed +0 -0
- headless_spreadsheets-0.1.0/src/headless_spreadsheets/rendering.py +160 -0
- headless_spreadsheets-0.1.0/src/headless_spreadsheets/serialization.py +222 -0
- headless_spreadsheets-0.1.0/src/headless_spreadsheets/table.py +414 -0
|
@@ -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
|