datalayer-cells 0.0.2__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.
Potentially problematic release.
This version of datalayer-cells might be problematic. Click here for more details.
- datalayer_cells/__init__.py +28 -0
- datalayer_cells/__version__.py +7 -0
- datalayer_cells/cli.py +167 -0
- datalayer_cells/format.py +260 -0
- datalayer_cells/workbook.py +308 -0
- datalayer_cells-0.0.2.dist-info/METADATA +104 -0
- datalayer_cells-0.0.2.dist-info/RECORD +10 -0
- datalayer_cells-0.0.2.dist-info/WHEEL +4 -0
- datalayer_cells-0.0.2.dist-info/entry_points.txt +5 -0
- datalayer_cells-0.0.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Copyright (c) 2022-2026 Datalayer, Inc.
|
|
2
|
+
#
|
|
3
|
+
# MIT License
|
|
4
|
+
|
|
5
|
+
"""Datalayer Cells for Python: read and write Cells workbooks, as agents do.
|
|
6
|
+
|
|
7
|
+
The ``CellsWorkbook`` JSON format with the same validation as
|
|
8
|
+
``@datalayer/cells``, and the ``datalayer cells`` command. Live rooms
|
|
9
|
+
(the Loro schema) come with the collaboration milestone.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .__version__ import __version__
|
|
13
|
+
from .format import CELL_ERRORS, FORMAT_VERSION, FormatError, validate_workbook
|
|
14
|
+
from .workbook import Workbook, column_index, column_letters, parse_a1, parse_input, parse_zone
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"CELL_ERRORS",
|
|
18
|
+
"FORMAT_VERSION",
|
|
19
|
+
"FormatError",
|
|
20
|
+
"Workbook",
|
|
21
|
+
"__version__",
|
|
22
|
+
"column_index",
|
|
23
|
+
"column_letters",
|
|
24
|
+
"parse_a1",
|
|
25
|
+
"parse_input",
|
|
26
|
+
"parse_zone",
|
|
27
|
+
"validate_workbook",
|
|
28
|
+
]
|
datalayer_cells/cli.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# Copyright (c) 2022-2026 Datalayer, Inc.
|
|
2
|
+
#
|
|
3
|
+
# MIT License
|
|
4
|
+
|
|
5
|
+
"""``datalayer cells``: read and write Cells workbooks from a terminal.
|
|
6
|
+
|
|
7
|
+
Two doors to the same commands, as Datalayer's other packages have:
|
|
8
|
+
installed beside the Datalayer CLI, the ``cells`` group joins the
|
|
9
|
+
``datalayer`` command through the ``datalayer.cli`` entry point; on its own,
|
|
10
|
+
``datalayer-cells`` runs it.
|
|
11
|
+
|
|
12
|
+
datalayer cells show budget.cells.json # the sheets and their used ranges
|
|
13
|
+
datalayer cells get budget.cells.json 'Summary!A1:C4'
|
|
14
|
+
datalayer cells set budget.cells.json B2 12 '=B1*2'
|
|
15
|
+
datalayer cells export budget.cells.json --sheet Summary --format csv
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import csv
|
|
21
|
+
import io
|
|
22
|
+
import json
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, List, Optional
|
|
25
|
+
|
|
26
|
+
import typer
|
|
27
|
+
from reactor import PluginManifest
|
|
28
|
+
|
|
29
|
+
from .__version__ import __version__
|
|
30
|
+
from .format import FormatError
|
|
31
|
+
from .workbook import Workbook, column_letters, parse_input
|
|
32
|
+
|
|
33
|
+
app = typer.Typer(
|
|
34
|
+
name="cells",
|
|
35
|
+
help="Read and write Datalayer Cells workbooks (.cells.json).",
|
|
36
|
+
no_args_is_help=True,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _load(path: Path) -> Workbook:
|
|
41
|
+
try:
|
|
42
|
+
return Workbook.load(path)
|
|
43
|
+
except (OSError, FormatError) as error:
|
|
44
|
+
typer.echo(f"Cannot read {path}: {error}", err=True)
|
|
45
|
+
raise typer.Exit(1) from error
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _show(value: Any) -> str:
|
|
49
|
+
if value is None:
|
|
50
|
+
return ""
|
|
51
|
+
if isinstance(value, dict):
|
|
52
|
+
return str(value.get("error"))
|
|
53
|
+
if isinstance(value, bool):
|
|
54
|
+
return "TRUE" if value else "FALSE"
|
|
55
|
+
return str(value)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command()
|
|
59
|
+
def show(path: Path) -> None:
|
|
60
|
+
"""The sheets of a workbook, and the range each one uses."""
|
|
61
|
+
wb = _load(path)
|
|
62
|
+
for sheet in wb.to_json()["sheets"]:
|
|
63
|
+
rows = [int(r) for r in sheet["rows"]]
|
|
64
|
+
cols = [int(c) for row in sheet["rows"].values() for c in row["cells"]]
|
|
65
|
+
used = f"A1:{column_letters(max(cols))}{max(rows) + 1}" if rows and cols else "empty"
|
|
66
|
+
count = sum(len(row["cells"]) for row in sheet["rows"].values())
|
|
67
|
+
typer.echo(f"{sheet['name']}\t{used}\t{count} cells")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command()
|
|
71
|
+
def get(path: Path, ref: str, formulas: bool = typer.Option(False, "--formulas", help="Show formulas as typed.")) -> None:
|
|
72
|
+
"""The values (or formulas) of a range, tab-separated."""
|
|
73
|
+
wb = _load(path)
|
|
74
|
+
try:
|
|
75
|
+
rows = wb.read(ref)
|
|
76
|
+
typed = wb.formulas(ref) if formulas else None
|
|
77
|
+
except (KeyError, ValueError) as error:
|
|
78
|
+
typer.echo(str(error).strip('"'), err=True)
|
|
79
|
+
raise typer.Exit(1) from error
|
|
80
|
+
for r, row in enumerate(rows):
|
|
81
|
+
typer.echo("\t".join(typed[r][c] or _show(v) if typed else _show(v) for c, v in enumerate(row)))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command(name="set")
|
|
85
|
+
def set_(
|
|
86
|
+
path: Path,
|
|
87
|
+
ref: str,
|
|
88
|
+
values: List[str] = typer.Argument(..., help="Values for a row from REF, typed as in a cell: 12, TRUE, =A1*2, 'text."),
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Write a row of values from a cell, as typing them would, and save."""
|
|
91
|
+
wb = _load(path)
|
|
92
|
+
row: list[Any] = []
|
|
93
|
+
for text in values:
|
|
94
|
+
parsed = parse_input(text)
|
|
95
|
+
row.append(f"={parsed['f']}" if "f" in parsed else parsed.get("v"))
|
|
96
|
+
try:
|
|
97
|
+
wb.write(ref, [row])
|
|
98
|
+
except (KeyError, ValueError) as error:
|
|
99
|
+
typer.echo(str(error).strip('"'), err=True)
|
|
100
|
+
raise typer.Exit(1) from error
|
|
101
|
+
wb.save(path)
|
|
102
|
+
typer.echo(f"Wrote {len(row)} cell(s) from {ref}.")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command()
|
|
106
|
+
def export(
|
|
107
|
+
path: Path,
|
|
108
|
+
sheet: Optional[str] = typer.Option(None, help="The sheet to export (the first by default)."),
|
|
109
|
+
format: str = typer.Option("csv", help="csv or json."),
|
|
110
|
+
) -> None:
|
|
111
|
+
"""A sheet as CSV (values) or the workbook as canonical JSON, on standard output."""
|
|
112
|
+
wb = _load(path)
|
|
113
|
+
if format == "json":
|
|
114
|
+
typer.echo(json.dumps(wb.to_json(), indent=2))
|
|
115
|
+
return
|
|
116
|
+
if format != "csv":
|
|
117
|
+
typer.echo(f"No export format '{format}': csv or json.", err=True)
|
|
118
|
+
raise typer.Exit(1)
|
|
119
|
+
data = wb.to_json()
|
|
120
|
+
names = [s["name"] for s in data["sheets"]]
|
|
121
|
+
chosen = sheet or names[0]
|
|
122
|
+
match = [s for s in data["sheets"] if s["name"].lower() == chosen.lower()]
|
|
123
|
+
if not match:
|
|
124
|
+
typer.echo(f"No sheet '{chosen}'; the workbook has {', '.join(names)}.", err=True)
|
|
125
|
+
raise typer.Exit(1)
|
|
126
|
+
rows = match[0]["rows"]
|
|
127
|
+
if not rows:
|
|
128
|
+
return
|
|
129
|
+
height = max(int(r) for r in rows) + 1
|
|
130
|
+
width = max(int(c) for row in rows.values() for c in row["cells"]) + 1
|
|
131
|
+
quoted = "'" + match[0]["name"].replace("'", "''") + "'"
|
|
132
|
+
values = wb.read(f"{quoted}!A1:{column_letters(width - 1)}{height}")
|
|
133
|
+
out = io.StringIO()
|
|
134
|
+
csv.writer(out, lineterminator="\n").writerows([[_show(v) for v in row] for row in values])
|
|
135
|
+
typer.echo(out.getvalue(), nl=False)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.command()
|
|
139
|
+
def version() -> None:
|
|
140
|
+
"""The version of datalayer_cells."""
|
|
141
|
+
typer.echo(__version__)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class CellsCliExtension:
|
|
145
|
+
"""The plugin: registers the ``cells`` group into the host CLI."""
|
|
146
|
+
|
|
147
|
+
def provide_cli(self, cli: "typer.Typer") -> None:
|
|
148
|
+
cli.add_typer(app, name="cells")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
manifest = PluginManifest(
|
|
152
|
+
name="cells-cli",
|
|
153
|
+
version=__version__,
|
|
154
|
+
description="The `cells` command group: show, get, set, export.",
|
|
155
|
+
author="Datalayer",
|
|
156
|
+
tags=["cli", "cells"],
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def plugin() -> tuple[PluginManifest, CellsCliExtension]:
|
|
161
|
+
"""What the ``datalayer.cli`` entry point resolves to."""
|
|
162
|
+
return manifest, CellsCliExtension()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def main() -> None:
|
|
166
|
+
"""The ``datalayer-cells`` console script: the same group, on its own."""
|
|
167
|
+
app()
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# Copyright (c) 2022-2026 Datalayer, Inc.
|
|
2
|
+
#
|
|
3
|
+
# MIT License
|
|
4
|
+
|
|
5
|
+
"""The ``CellsWorkbook`` format: validation with the TypeScript loader's refusals.
|
|
6
|
+
|
|
7
|
+
A file that does not match is refused with a sentence naming the path of the
|
|
8
|
+
first bad field, the same sentence ``@datalayer/cells`` gives; both read the
|
|
9
|
+
shared corpus under ``tests/format`` to keep it so.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
import re
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
FORMAT_VERSION = 1
|
|
20
|
+
|
|
21
|
+
#: The errors a cell can hold, as Excel spells them (``#CYCLE!`` and ``#BUSY!`` are Cells').
|
|
22
|
+
CELL_ERRORS = (
|
|
23
|
+
"#DIV/0!",
|
|
24
|
+
"#REF!",
|
|
25
|
+
"#NAME?",
|
|
26
|
+
"#VALUE!",
|
|
27
|
+
"#N/A",
|
|
28
|
+
"#NUM!",
|
|
29
|
+
"#NULL!",
|
|
30
|
+
"#CYCLE!",
|
|
31
|
+
"#BUSY!",
|
|
32
|
+
"#SPILL!",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
STYLE_KEYS = (
|
|
36
|
+
"font",
|
|
37
|
+
"size",
|
|
38
|
+
"bold",
|
|
39
|
+
"italic",
|
|
40
|
+
"underline",
|
|
41
|
+
"strike",
|
|
42
|
+
"color",
|
|
43
|
+
"fill",
|
|
44
|
+
"align",
|
|
45
|
+
"valign",
|
|
46
|
+
"wrap",
|
|
47
|
+
"borders",
|
|
48
|
+
"format",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
#: The number formats Cells shows today; a style with another is refused, as in TypeScript.
|
|
52
|
+
BUILT_IN_FORMATS = (
|
|
53
|
+
"General",
|
|
54
|
+
"0",
|
|
55
|
+
"0.00",
|
|
56
|
+
"#,##0",
|
|
57
|
+
"#,##0.00",
|
|
58
|
+
"0%",
|
|
59
|
+
"0.00%",
|
|
60
|
+
"yyyy-mm-dd",
|
|
61
|
+
"hh:mm",
|
|
62
|
+
"yyyy-mm-dd hh:mm",
|
|
63
|
+
"@",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
#: Excel's grid, as the TypeScript model's default limits.
|
|
67
|
+
DEFAULT_LIMITS = {"rows": 1_048_576, "cols": 16_384}
|
|
68
|
+
|
|
69
|
+
_INDEX = re.compile(r"^(0|[1-9][0-9]*)$")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class FormatError(ValueError):
|
|
73
|
+
"""A file refused: the message names the path of the first bad field."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _fail(path: str, message: str) -> None:
|
|
77
|
+
raise FormatError(f"{path}: {message}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _js(value: Any) -> str:
|
|
81
|
+
"""What JavaScript's JSON.stringify prints, for messages that quote a value."""
|
|
82
|
+
if isinstance(value, float) and value.is_integer() and not isinstance(value, bool):
|
|
83
|
+
return str(int(value))
|
|
84
|
+
if value is None:
|
|
85
|
+
return "undefined"
|
|
86
|
+
return json.dumps(value, separators=(",", ":"))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _is_object(x: Any) -> bool:
|
|
90
|
+
return isinstance(x, dict)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _is_int(x: Any) -> bool:
|
|
94
|
+
return (isinstance(x, int) and not isinstance(x, bool)) or (isinstance(x, float) and x.is_integer())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _is_number(x: Any) -> bool:
|
|
98
|
+
return isinstance(x, (int, float)) and not isinstance(x, bool)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _is_index(key: str, maximum: int) -> bool:
|
|
102
|
+
return bool(_INDEX.match(key)) and int(key) < maximum
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def check_style(style: Any, where: str = "style") -> None:
|
|
106
|
+
if not _is_object(style):
|
|
107
|
+
_fail(where, "a style is an object.")
|
|
108
|
+
for key in style:
|
|
109
|
+
if key not in STYLE_KEYS:
|
|
110
|
+
raise FormatError(f"{where}: '{key}' is not a style property ({', '.join(STYLE_KEYS)}).")
|
|
111
|
+
for key in ("bold", "italic", "underline", "strike", "wrap"):
|
|
112
|
+
if key in style and not isinstance(style[key], bool):
|
|
113
|
+
raise FormatError(f"{where}.{key}: true or false.")
|
|
114
|
+
for key in ("font", "color", "fill", "format"):
|
|
115
|
+
if key in style and not isinstance(style[key], str):
|
|
116
|
+
raise FormatError(f"{where}.{key}: a string.")
|
|
117
|
+
if "format" in style and style["format"] not in BUILT_IN_FORMATS:
|
|
118
|
+
raise FormatError(
|
|
119
|
+
f"{where}.format: '{style['format']}' is not one of the built-in formats "
|
|
120
|
+
f"({', '.join(BUILT_IN_FORMATS)}); the full grammar is not built yet."
|
|
121
|
+
)
|
|
122
|
+
if "size" in style and not (_is_number(style["size"]) and style["size"] > 0):
|
|
123
|
+
raise FormatError(f"{where}.size: a positive number.")
|
|
124
|
+
if "align" in style and style["align"] not in ("left", "center", "right"):
|
|
125
|
+
raise FormatError(f"{where}.align: left, center or right.")
|
|
126
|
+
if "valign" in style and style["valign"] not in ("top", "middle", "bottom"):
|
|
127
|
+
raise FormatError(f"{where}.valign: top, middle or bottom.")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _check_value(v: Any, path: str) -> None:
|
|
131
|
+
if v is None or isinstance(v, (str, bool)):
|
|
132
|
+
return
|
|
133
|
+
if _is_number(v):
|
|
134
|
+
if not math.isfinite(v):
|
|
135
|
+
_fail(path, f"{v} is not a value")
|
|
136
|
+
return
|
|
137
|
+
if _is_object(v) and isinstance(v.get("error"), str) and len(v) == 1:
|
|
138
|
+
if v["error"] not in CELL_ERRORS:
|
|
139
|
+
_fail(path, f"'{v['error']}' is not an error code")
|
|
140
|
+
return
|
|
141
|
+
_fail(path, f"{_js(v)} is not a value (a number, a string, a boolean, null or {{ error }})")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _check_zone(z: Any, path: str, limits: dict[str, int]) -> None:
|
|
145
|
+
if not _is_object(z):
|
|
146
|
+
_fail(path, "a zone is { top, left, bottom, right }")
|
|
147
|
+
for k in ("top", "left", "bottom", "right"):
|
|
148
|
+
if not _is_int(z.get(k)) or z[k] < 0:
|
|
149
|
+
_fail(f"{path}.{k}", "a non-negative integer")
|
|
150
|
+
if z["top"] > z["bottom"] or z["left"] > z["right"]:
|
|
151
|
+
_fail(path, "top-left after bottom-right")
|
|
152
|
+
if z["bottom"] >= limits["rows"] or z["right"] >= limits["cols"]:
|
|
153
|
+
_fail(path, "outside the sheet")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def validate_workbook(data: Any, limits: dict[str, int] | None = None) -> dict[str, Any]:
|
|
157
|
+
"""Return ``data`` when it is a valid CellsWorkbook; raise :class:`FormatError` otherwise."""
|
|
158
|
+
limits = limits or DEFAULT_LIMITS
|
|
159
|
+
if not _is_object(data):
|
|
160
|
+
_fail("workbook", "a CellsWorkbook is a JSON object")
|
|
161
|
+
if data.get("version") != FORMAT_VERSION or isinstance(data.get("version"), bool):
|
|
162
|
+
_fail("version", f"this is format version {_js(data.get('version'))}; Cells reads version {FORMAT_VERSION}")
|
|
163
|
+
styles = data.get("styles", [])
|
|
164
|
+
if not isinstance(styles, list):
|
|
165
|
+
_fail("styles", "a list")
|
|
166
|
+
for i, s in enumerate(styles):
|
|
167
|
+
check_style(s, f"styles[{i}]")
|
|
168
|
+
if styles and len(styles[0]) > 0:
|
|
169
|
+
_fail("styles[0]", "style 0 is the default: {}")
|
|
170
|
+
sheets = data.get("sheets")
|
|
171
|
+
if not isinstance(sheets, list) or not sheets:
|
|
172
|
+
_fail("sheets", "a non-empty list")
|
|
173
|
+
ids: set[str] = set()
|
|
174
|
+
names: set[str] = set()
|
|
175
|
+
for i, sheet in enumerate(sheets):
|
|
176
|
+
p = f"sheets[{i}]"
|
|
177
|
+
if not _is_object(sheet):
|
|
178
|
+
_fail(p, "a sheet is an object")
|
|
179
|
+
sid = sheet.get("id")
|
|
180
|
+
if not isinstance(sid, str) or sid == "":
|
|
181
|
+
_fail(f"{p}.id", "a non-empty string")
|
|
182
|
+
if sid in ids:
|
|
183
|
+
_fail(f"{p}.id", f"'{sid}' is used twice")
|
|
184
|
+
ids.add(sid)
|
|
185
|
+
name = sheet.get("name")
|
|
186
|
+
if not isinstance(name, str) or name.strip() == "":
|
|
187
|
+
_fail(f"{p}.name", "a non-empty string")
|
|
188
|
+
if name.lower() in names:
|
|
189
|
+
_fail(f"{p}.name", f"'{name}' is used twice")
|
|
190
|
+
names.add(name.lower())
|
|
191
|
+
rows = sheet.get("rows")
|
|
192
|
+
if not _is_object(rows):
|
|
193
|
+
_fail(f"{p}.rows", "an object keyed by row index")
|
|
194
|
+
for r, row in rows.items():
|
|
195
|
+
rp = f'{p}.rows["{r}"]'
|
|
196
|
+
if not _is_index(r, limits["rows"]):
|
|
197
|
+
_fail(rp, f"'{r}' is not a row index below {limits['rows']}")
|
|
198
|
+
if not _is_object(row) or not _is_object(row.get("cells")):
|
|
199
|
+
_fail(rp, "a row is { cells: { … } }")
|
|
200
|
+
for c, cell in row["cells"].items():
|
|
201
|
+
cp = f'{rp}.cells["{c}"]'
|
|
202
|
+
if not _is_index(c, limits["cols"]):
|
|
203
|
+
_fail(cp, f"'{c}' is not a column index below {limits['cols']}")
|
|
204
|
+
if not _is_object(cell):
|
|
205
|
+
_fail(cp, "a cell is { v?, f?, s? }")
|
|
206
|
+
for k in cell:
|
|
207
|
+
if k not in ("v", "f", "s"):
|
|
208
|
+
_fail(f"{cp}.{k}", "a cell has v, f and s only")
|
|
209
|
+
if "v" in cell:
|
|
210
|
+
_check_value(cell["v"], f"{cp}.v")
|
|
211
|
+
if "f" in cell and (not isinstance(cell["f"], str) or cell["f"] == ""):
|
|
212
|
+
_fail(f"{cp}.f", "a formula without the =")
|
|
213
|
+
if "s" in cell:
|
|
214
|
+
s = cell["s"]
|
|
215
|
+
if not _is_int(s) or s < 0 or s >= max(1, len(styles)):
|
|
216
|
+
_fail(f"{cp}.s", f"no style {_js(s)}")
|
|
217
|
+
sizes = sheet.get("sizes")
|
|
218
|
+
if sizes is not None:
|
|
219
|
+
if not _is_object(sizes):
|
|
220
|
+
_fail(f"{p}.sizes", "{ rows?, cols? }")
|
|
221
|
+
for axis in ("rows", "cols"):
|
|
222
|
+
if axis not in sizes:
|
|
223
|
+
continue
|
|
224
|
+
if not _is_object(sizes[axis]):
|
|
225
|
+
_fail(f"{p}.sizes.{axis}", "an object keyed by index")
|
|
226
|
+
for k, v in sizes[axis].items():
|
|
227
|
+
if not _is_index(k, limits[axis]):
|
|
228
|
+
_fail(f'{p}.sizes.{axis}["{k}"]', "not an index of the sheet")
|
|
229
|
+
if not (_is_number(v) and v > 0 and math.isfinite(v)):
|
|
230
|
+
_fail(f'{p}.sizes.{axis}["{k}"]', "a positive size")
|
|
231
|
+
hidden = sheet.get("hidden")
|
|
232
|
+
if hidden is not None:
|
|
233
|
+
if not _is_object(hidden):
|
|
234
|
+
_fail(f"{p}.hidden", "{ rows?, cols? }")
|
|
235
|
+
for axis in ("rows", "cols"):
|
|
236
|
+
if axis not in hidden:
|
|
237
|
+
continue
|
|
238
|
+
if not isinstance(hidden[axis], list):
|
|
239
|
+
_fail(f"{p}.hidden.{axis}", "a list of indexes")
|
|
240
|
+
for j, x in enumerate(hidden[axis]):
|
|
241
|
+
if not _is_int(x) or x < 0 or x >= limits[axis]:
|
|
242
|
+
_fail(f"{p}.hidden.{axis}[{j}]", "not an index of the sheet")
|
|
243
|
+
merges = sheet.get("merges")
|
|
244
|
+
if merges is not None:
|
|
245
|
+
if not isinstance(merges, list):
|
|
246
|
+
_fail(f"{p}.merges", "a list of zones")
|
|
247
|
+
for j, z in enumerate(merges):
|
|
248
|
+
_check_zone(z, f"{p}.merges[{j}]", limits)
|
|
249
|
+
frozen = sheet.get("frozen")
|
|
250
|
+
if frozen is not None:
|
|
251
|
+
ok = (
|
|
252
|
+
_is_object(frozen)
|
|
253
|
+
and _is_int(frozen.get("rows"))
|
|
254
|
+
and _is_int(frozen.get("cols"))
|
|
255
|
+
and frozen["rows"] >= 0
|
|
256
|
+
and frozen["cols"] >= 0
|
|
257
|
+
)
|
|
258
|
+
if not ok:
|
|
259
|
+
_fail(f"{p}.frozen", "{ rows, cols }, non-negative integers")
|
|
260
|
+
return data
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# Copyright (c) 2022-2026 Datalayer, Inc.
|
|
2
|
+
#
|
|
3
|
+
# MIT License
|
|
4
|
+
|
|
5
|
+
"""A workbook as an agent reads and writes it: values and formulas by A1 reference.
|
|
6
|
+
|
|
7
|
+
wb = Workbook.load("budget.cells.json")
|
|
8
|
+
wb.read("Summary!A1:C4") # rows of values
|
|
9
|
+
wb.write("B2", [[12, "=B1*2"]]) # a block from B2
|
|
10
|
+
wb.save("budget.cells.json")
|
|
11
|
+
|
|
12
|
+
Formulas are kept as typed and are not evaluated here, as in the TypeScript
|
|
13
|
+
model until its formula engine lands: reading a formula cell gives its cached
|
|
14
|
+
value, ``None`` when it has none.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import math
|
|
21
|
+
import re
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Union
|
|
24
|
+
|
|
25
|
+
from .format import CELL_ERRORS, DEFAULT_LIMITS, FORMAT_VERSION, FormatError, check_style, validate_workbook
|
|
26
|
+
|
|
27
|
+
Value = Union[float, int, str, bool, None, dict]
|
|
28
|
+
|
|
29
|
+
_CELL = re.compile(r"^\$?([A-Za-z]{1,3})\$?([1-9][0-9]{0,6})$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def column_index(letters: str) -> int:
|
|
33
|
+
"""``A`` → 0, ``AA`` → 26."""
|
|
34
|
+
if not re.fullmatch(r"[A-Za-z]{1,3}", letters):
|
|
35
|
+
raise ValueError(f"'{letters}' is not a column (A to XFD).")
|
|
36
|
+
n = 0
|
|
37
|
+
for ch in letters.upper():
|
|
38
|
+
n = n * 26 + (ord(ch) - 64)
|
|
39
|
+
return n - 1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def column_letters(col: int) -> str:
|
|
43
|
+
"""0 → ``A``, 26 → ``AA``."""
|
|
44
|
+
if col < 0:
|
|
45
|
+
raise ValueError(f"No column at index {col}.")
|
|
46
|
+
name = ""
|
|
47
|
+
n = col + 1
|
|
48
|
+
while n > 0:
|
|
49
|
+
n, rest = divmod(n - 1, 26)
|
|
50
|
+
name = chr(65 + rest) + name
|
|
51
|
+
return name
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_a1(text: str) -> tuple[int, int]:
|
|
55
|
+
"""``B3`` → (row 2, col 1)."""
|
|
56
|
+
m = _CELL.match(text.strip())
|
|
57
|
+
if not m:
|
|
58
|
+
raise ValueError(f"'{text}' is not a cell reference such as B3.")
|
|
59
|
+
return int(m.group(2)) - 1, column_index(m.group(1))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def split_sheet(ref: str) -> tuple[str | None, str]:
|
|
63
|
+
"""``'My sheet'!A1:B2`` → (``My sheet``, ``A1:B2``)."""
|
|
64
|
+
bang = ref.rfind("!")
|
|
65
|
+
if bang < 0:
|
|
66
|
+
return None, ref
|
|
67
|
+
sheet = ref[:bang]
|
|
68
|
+
if sheet.startswith("'"):
|
|
69
|
+
if not sheet.endswith("'") or len(sheet) < 2:
|
|
70
|
+
raise ValueError(f"'{ref}' has an unclosed sheet name.")
|
|
71
|
+
sheet = sheet[1:-1].replace("''", "'")
|
|
72
|
+
if sheet == "":
|
|
73
|
+
raise ValueError(f"'{ref}' names an empty sheet.")
|
|
74
|
+
return sheet, ref[bang + 1 :]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_zone(text: str) -> tuple[int, int, int, int]:
|
|
78
|
+
"""``A1:C4`` → (top, left, bottom, right), 0-based and inclusive."""
|
|
79
|
+
parts = text.strip().split(":")
|
|
80
|
+
if len(parts) > 2 or parts[0] == "":
|
|
81
|
+
raise ValueError(f"'{text}' is not a range such as A1:C4.")
|
|
82
|
+
r0, c0 = parse_a1(parts[0])
|
|
83
|
+
r1, c1 = parse_a1(parts[1]) if len(parts) == 2 else (r0, c0)
|
|
84
|
+
return min(r0, r1), min(c0, c1), max(r0, r1), max(c0, c1)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_error(value: Any) -> bool:
|
|
88
|
+
return isinstance(value, dict) and set(value) == {"error"} and value["error"] in CELL_ERRORS
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse_input(text: str) -> dict[str, Any]:
|
|
92
|
+
"""What typing ``text`` into a cell means, as the TypeScript ``parseInput``: a cell's ``v`` or ``f``."""
|
|
93
|
+
if text.startswith("'"):
|
|
94
|
+
return {"v": text[1:]}
|
|
95
|
+
if text.startswith("=") and len(text) > 1:
|
|
96
|
+
return {"f": text[1:]}
|
|
97
|
+
t = text.strip()
|
|
98
|
+
if t and re.fullmatch(r"[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?", t):
|
|
99
|
+
n = float(t)
|
|
100
|
+
return {"v": int(n) if n.is_integer() and "." not in t and "e" not in t.lower() else n}
|
|
101
|
+
if t.lower() == "true":
|
|
102
|
+
return {"v": True}
|
|
103
|
+
if t.lower() == "false":
|
|
104
|
+
return {"v": False}
|
|
105
|
+
return {} if text == "" else {"v": text}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Workbook:
|
|
109
|
+
"""A CellsWorkbook in memory: sheets of sparse cells, styles by index."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, data: dict[str, Any]):
|
|
112
|
+
validate_workbook(data)
|
|
113
|
+
self._styles: list[dict[str, Any]] = [dict(s) for s in data.get("styles", [])] or [{}]
|
|
114
|
+
self._sheets: list[dict[str, Any]] = []
|
|
115
|
+
for s in data["sheets"]:
|
|
116
|
+
cells: dict[tuple[int, int], dict[str, Any]] = {}
|
|
117
|
+
for r, row in s["rows"].items():
|
|
118
|
+
for c, cell in row["cells"].items():
|
|
119
|
+
kept = {k: v for k, v in cell.items() if not (k == "v" and v is None)}
|
|
120
|
+
if kept.get("s") == 0:
|
|
121
|
+
kept.pop("s")
|
|
122
|
+
if kept:
|
|
123
|
+
cells[(int(r), int(c))] = kept
|
|
124
|
+
sheet = {k: v for k, v in s.items() if k != "rows"}
|
|
125
|
+
sheet["cells"] = cells
|
|
126
|
+
self._sheets.append(sheet)
|
|
127
|
+
|
|
128
|
+
# ------------------------------------------------------------------ files
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def load(cls, path: Union[str, Path]) -> "Workbook":
|
|
132
|
+
try:
|
|
133
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
134
|
+
except json.JSONDecodeError as error:
|
|
135
|
+
raise FormatError(f"workbook: {path} is not JSON ({error})") from error
|
|
136
|
+
return cls(data)
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def from_rows(cls, rows: list[list[Value]], name: str = "Sheet1") -> "Workbook":
|
|
140
|
+
"""A workbook of one sheet from rows of values."""
|
|
141
|
+
out: dict[str, Any] = {}
|
|
142
|
+
for r, row in enumerate(rows):
|
|
143
|
+
for c, v in enumerate(row):
|
|
144
|
+
if v is not None:
|
|
145
|
+
out.setdefault(str(r), {"cells": {}})["cells"][str(c)] = {"v": v}
|
|
146
|
+
return cls({"version": FORMAT_VERSION, "sheets": [{"id": "s1", "name": name, "rows": out}], "styles": []})
|
|
147
|
+
|
|
148
|
+
def save(self, path: Union[str, Path]) -> None:
|
|
149
|
+
Path(path).write_text(json.dumps(self.to_json(), indent=2) + "\n", encoding="utf-8")
|
|
150
|
+
|
|
151
|
+
def to_json(self) -> dict[str, Any]:
|
|
152
|
+
"""The workbook as CellsWorkbook JSON, in the TypeScript model's canonical form."""
|
|
153
|
+
used: dict[int, int] = {0: 0}
|
|
154
|
+
styles: list[dict[str, Any]] = [{}]
|
|
155
|
+
sheets = []
|
|
156
|
+
for sheet in self._sheets:
|
|
157
|
+
rows: dict[str, Any] = {}
|
|
158
|
+
for (r, c) in sorted(sheet["cells"]):
|
|
159
|
+
cell = sheet["cells"][(r, c)]
|
|
160
|
+
out: dict[str, Any] = {}
|
|
161
|
+
if cell.get("v") is not None:
|
|
162
|
+
out["v"] = cell["v"]
|
|
163
|
+
if "f" in cell:
|
|
164
|
+
out["f"] = cell["f"]
|
|
165
|
+
s = cell.get("s", 0)
|
|
166
|
+
if s != 0:
|
|
167
|
+
if s not in used:
|
|
168
|
+
used[s] = len(styles)
|
|
169
|
+
styles.append(self._styles[s])
|
|
170
|
+
out["s"] = used[s]
|
|
171
|
+
rows.setdefault(str(r), {"cells": {}})["cells"][str(c)] = out
|
|
172
|
+
item: dict[str, Any] = {"id": sheet["id"], "name": sheet["name"], "rows": rows}
|
|
173
|
+
for key in ("sizes", "hidden", "merges", "frozen"):
|
|
174
|
+
if key in sheet and sheet[key]:
|
|
175
|
+
if key == "frozen" and sheet[key] == {"rows": 0, "cols": 0}:
|
|
176
|
+
continue
|
|
177
|
+
item[key] = sheet[key]
|
|
178
|
+
sheets.append(item)
|
|
179
|
+
return {"version": FORMAT_VERSION, "sheets": sheets, "styles": styles}
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------------ reading
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def sheet_names(self) -> list[str]:
|
|
185
|
+
return [s["name"] for s in self._sheets]
|
|
186
|
+
|
|
187
|
+
def _sheet(self, name: str | None) -> dict[str, Any]:
|
|
188
|
+
if name is None:
|
|
189
|
+
return self._sheets[0]
|
|
190
|
+
for s in self._sheets:
|
|
191
|
+
if s["name"].lower() == name.lower():
|
|
192
|
+
return s
|
|
193
|
+
raise KeyError(f"No sheet '{name}'; the workbook has {', '.join(self.sheet_names)}.")
|
|
194
|
+
|
|
195
|
+
def _zone(self, ref: str) -> tuple[dict[str, Any], tuple[int, int, int, int]]:
|
|
196
|
+
name, a1 = split_sheet(ref)
|
|
197
|
+
sheet = self._sheet(name)
|
|
198
|
+
zone = parse_zone(a1)
|
|
199
|
+
if zone[2] >= DEFAULT_LIMITS["rows"] or zone[3] >= DEFAULT_LIMITS["cols"]:
|
|
200
|
+
raise ValueError(f"'{ref}' reaches outside the sheet.")
|
|
201
|
+
return sheet, zone
|
|
202
|
+
|
|
203
|
+
def cell(self, ref: str) -> dict[str, Any] | None:
|
|
204
|
+
"""What a cell holds: ``{'v': …}``, ``{'f': …, 'v': cached}``, with ``'s'`` for a style; ``None`` when empty."""
|
|
205
|
+
sheet, (r, c, r1, c1) = self._zone(ref)
|
|
206
|
+
if (r, c) != (r1, c1):
|
|
207
|
+
raise ValueError(f"'{ref}' is a range; cell() takes one cell.")
|
|
208
|
+
found = sheet["cells"].get((r, c))
|
|
209
|
+
return dict(found) if found else None
|
|
210
|
+
|
|
211
|
+
def read(self, ref: str) -> list[list[Value]]:
|
|
212
|
+
"""The values a range shows, as rows: a formula shows its cached value."""
|
|
213
|
+
sheet, (top, left, bottom, right) = self._zone(ref)
|
|
214
|
+
cells = sheet["cells"]
|
|
215
|
+
return [[(cells.get((r, c)) or {}).get("v") for c in range(left, right + 1)] for r in range(top, bottom + 1)]
|
|
216
|
+
|
|
217
|
+
def formulas(self, ref: str) -> list[list[str | None]]:
|
|
218
|
+
"""The formulas of a range as typed (with ``=``), ``None`` where a cell holds none."""
|
|
219
|
+
sheet, (top, left, bottom, right) = self._zone(ref)
|
|
220
|
+
cells = sheet["cells"]
|
|
221
|
+
out = []
|
|
222
|
+
for r in range(top, bottom + 1):
|
|
223
|
+
row = []
|
|
224
|
+
for c in range(left, right + 1):
|
|
225
|
+
f = (cells.get((r, c)) or {}).get("f")
|
|
226
|
+
row.append(None if f is None else f"={f}")
|
|
227
|
+
out.append(row)
|
|
228
|
+
return out
|
|
229
|
+
|
|
230
|
+
# ------------------------------------------------------------------ writing
|
|
231
|
+
|
|
232
|
+
def write(self, ref: str, rows: list[list[Any]]) -> None:
|
|
233
|
+
"""Write a block of rows from the top-left of ``ref``. A string starting with ``=`` is a formula;
|
|
234
|
+
``None`` empties a cell's content and keeps its style."""
|
|
235
|
+
sheet, (top, left, _, _) = self._zone(ref)
|
|
236
|
+
if not isinstance(rows, list) or not rows or not all(isinstance(r, list) for r in rows):
|
|
237
|
+
raise ValueError("write takes a non-empty list of rows.")
|
|
238
|
+
width = max(len(r) for r in rows)
|
|
239
|
+
if top + len(rows) > DEFAULT_LIMITS["rows"] or left + width > DEFAULT_LIMITS["cols"]:
|
|
240
|
+
raise ValueError(f"The block at '{ref}' reaches outside the sheet.")
|
|
241
|
+
planned: dict[tuple[int, int], dict[str, Any] | None] = {}
|
|
242
|
+
for dr, row in enumerate(rows):
|
|
243
|
+
for dc, value in enumerate(row):
|
|
244
|
+
pos = (top + dr, left + dc)
|
|
245
|
+
previous = sheet["cells"].get(pos, {})
|
|
246
|
+
cell: dict[str, Any] = {"s": previous["s"]} if "s" in previous else {}
|
|
247
|
+
if isinstance(value, str) and value.startswith("=") and len(value) > 1:
|
|
248
|
+
cell["f"] = value[1:]
|
|
249
|
+
elif value is not None:
|
|
250
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
251
|
+
raise ValueError(f"{value} is not a cell value.")
|
|
252
|
+
if isinstance(value, dict) and not is_error(value):
|
|
253
|
+
raise ValueError(f"{value!r} is not a cell value (an error is {{'error': '#N/A'}}).")
|
|
254
|
+
if not isinstance(value, (int, float, str, bool, dict)):
|
|
255
|
+
raise ValueError(f"{value!r} is not a cell value.")
|
|
256
|
+
cell["v"] = value
|
|
257
|
+
planned[pos] = cell or None
|
|
258
|
+
for pos, cell in planned.items():
|
|
259
|
+
if cell is None:
|
|
260
|
+
sheet["cells"].pop(pos, None)
|
|
261
|
+
else:
|
|
262
|
+
sheet["cells"][pos] = cell
|
|
263
|
+
|
|
264
|
+
def clear(self, ref: str) -> None:
|
|
265
|
+
"""Empty a range: values, formulas and styles."""
|
|
266
|
+
sheet, (top, left, bottom, right) = self._zone(ref)
|
|
267
|
+
for pos in [p for p in sheet["cells"] if top <= p[0] <= bottom and left <= p[1] <= right]:
|
|
268
|
+
del sheet["cells"][pos]
|
|
269
|
+
|
|
270
|
+
def style(self, ref: str, style: dict[str, Any] | None) -> None:
|
|
271
|
+
"""Merge ``style`` into every cell of a range (``None`` resets to the default)."""
|
|
272
|
+
if style is not None:
|
|
273
|
+
check_style(style)
|
|
274
|
+
sheet, (top, left, bottom, right) = self._zone(ref)
|
|
275
|
+
for r in range(top, bottom + 1):
|
|
276
|
+
for c in range(left, right + 1):
|
|
277
|
+
cell = dict(sheet["cells"].get((r, c), {}))
|
|
278
|
+
if style is None:
|
|
279
|
+
cell.pop("s", None)
|
|
280
|
+
else:
|
|
281
|
+
merged = {**self._styles[cell.get("s", 0)], **style}
|
|
282
|
+
merged = {k: v for k, v in merged.items() if v is not False}
|
|
283
|
+
cell["s"] = self._intern(merged)
|
|
284
|
+
if cell["s"] == 0:
|
|
285
|
+
cell.pop("s")
|
|
286
|
+
if cell:
|
|
287
|
+
sheet["cells"][(r, c)] = cell
|
|
288
|
+
else:
|
|
289
|
+
sheet["cells"].pop((r, c), None)
|
|
290
|
+
|
|
291
|
+
def add_sheet(self, name: str) -> None:
|
|
292
|
+
if not name.strip() or len(name) > 31 or re.search(r"[\\/?*\[\]:]", name):
|
|
293
|
+
raise ValueError(f"'{name}' is not a sheet name.")
|
|
294
|
+
if any(s["name"].lower() == name.lower() for s in self._sheets):
|
|
295
|
+
raise ValueError(f"A sheet is called '{name}' already.")
|
|
296
|
+
ids = {s["id"] for s in self._sheets}
|
|
297
|
+
n = 1
|
|
298
|
+
while f"s{n}" in ids:
|
|
299
|
+
n += 1
|
|
300
|
+
self._sheets.append({"id": f"s{n}", "name": name, "cells": {}})
|
|
301
|
+
|
|
302
|
+
def _intern(self, style: dict[str, Any]) -> int:
|
|
303
|
+
canonical = {k: style[k] for k in sorted(style)}
|
|
304
|
+
for i, s in enumerate(self._styles):
|
|
305
|
+
if {k: s[k] for k in sorted(s)} == canonical:
|
|
306
|
+
return i
|
|
307
|
+
self._styles.append(canonical)
|
|
308
|
+
return len(self._styles) - 1
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: datalayer_cells
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Datalayer Cells for Python: read and write Cells workbooks, as agents do, and the `datalayer cells` command
|
|
5
|
+
Project-URL: Homepage, https://github.com/datalayer/cells
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: datalayer-reactor>=1.3.1
|
|
10
|
+
Requires-Dist: typer>=0.12
|
|
11
|
+
Provides-Extra: test
|
|
12
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
[](https://datalayer.ai)
|
|
16
|
+
|
|
17
|
+
[](https://github.com/sponsors/datalayer)
|
|
18
|
+
|
|
19
|
+
# ☰ 🦠 Datalayer Cells
|
|
20
|
+
|
|
21
|
+
A spreadsheet drawn on canvas and exposed as a React component, over a
|
|
22
|
+
headless model that also runs in Node and in workers. Cells is built on
|
|
23
|
+
[Reactor](https://github.com/datalayer/reactor): every extension point (formula
|
|
24
|
+
functions, cell types, commands, importers and exporters, data sources, the
|
|
25
|
+
collaboration provider) is a Reactor contribution point, and the built-in
|
|
26
|
+
features are Reactor plugins contributing through the same points.
|
|
27
|
+
|
|
28
|
+
> Work in progress: nothing is published yet.
|
|
29
|
+
|
|
30
|
+
## Packages
|
|
31
|
+
|
|
32
|
+
| Import | What |
|
|
33
|
+
| ------------------------- | ------------------------------------------------------- |
|
|
34
|
+
| `@datalayer/cells` | the React component and hooks; re-exports `./core` |
|
|
35
|
+
| `@datalayer/cells/core` | the headless model: no React, no DOM |
|
|
36
|
+
| `@datalayer/cells/csv` | CSV and TSV in and out |
|
|
37
|
+
| `@datalayer/cells/xlsx` | XLSX in and out, loaded lazily |
|
|
38
|
+
| `@datalayer/cells/collab` | real-time collaboration over Loro |
|
|
39
|
+
| `@datalayer/cells/chrome` | toolbar, formula bar and sheet tabs |
|
|
40
|
+
| `@datalayer/cells/plugin` | views for a Reactor shell |
|
|
41
|
+
|
|
42
|
+
## Python
|
|
43
|
+
|
|
44
|
+
`datalayer_cells` reads and writes the same workbooks, with the same
|
|
45
|
+
validation, for agents and scripts, and adds the `datalayer cells` command
|
|
46
|
+
(also `datalayer-cells` on its own):
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from datalayer_cells import Workbook
|
|
50
|
+
|
|
51
|
+
wb = Workbook.load("budget.cells.json")
|
|
52
|
+
wb.read("Summary!A1:C4") # rows of values
|
|
53
|
+
wb.write("B2", [[12, "=B1*2"]]) # formulas are kept as typed
|
|
54
|
+
wb.save("budget.cells.json")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
datalayer cells show budget.cells.json
|
|
59
|
+
datalayer cells get budget.cells.json 'Summary!A1:C4'
|
|
60
|
+
datalayer cells set budget.cells.json B2 12 '=B1*2'
|
|
61
|
+
datalayer cells export budget.cells.json --format csv
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Examples
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
make examples # http://127.0.0.1:3077/
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
A page with a menu of examples, from a few values to collaboration, each with
|
|
71
|
+
its source. What is not built yet says so, and names the milestone that
|
|
72
|
+
brings it.
|
|
73
|
+
|
|
74
|
+
## Development
|
|
75
|
+
|
|
76
|
+
`@datalayer/reactor` resolves as a package: from npm, or from the workspace in
|
|
77
|
+
the Datalayer monorepo (install at its root).
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npm install
|
|
81
|
+
npm run lint # Prettier and the licence header on every file
|
|
82
|
+
npm run typecheck # everything, tests and examples included
|
|
83
|
+
npm test # unit tests (vitest, two workers), the React tests under React 19 and 18.3
|
|
84
|
+
npm run build # lib/
|
|
85
|
+
npm run bench # Node benches into bench/results/
|
|
86
|
+
python -m pytest # the Python package (pip install -e ".[test]" first)
|
|
87
|
+
npm run size # bundle sizes against their budgets
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Browser benches and end-to-end tests run on the system Chrome, headless:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
env -u WAYLAND_DISPLAY -u DISPLAY node scripts/bench.mjs --browser
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Credits
|
|
97
|
+
|
|
98
|
+
Cells was designed after reading [x-spreadsheet](https://github.com/myliang/x-spreadsheet)
|
|
99
|
+
(MIT) and [o-spreadsheet](https://github.com/odoo/o-spreadsheet) (LGPLv3) for
|
|
100
|
+
ideas; no code of either is included. See [THIRD_PARTY.md](THIRD_PARTY.md).
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
datalayer_cells/__init__.py,sha256=8K2IBLkEl2tXFUf6p8hH6xUWvdyGrup1sa2w0Jq2wyw,774
|
|
2
|
+
datalayer_cells/__version__.py,sha256=sTGaa9VpLhcTgVRnVMaZFmLqEQQrYHgrczyt8iGDnRY,168
|
|
3
|
+
datalayer_cells/cli.py,sha256=qETUalhzQQUOToIJa7uVGroNCWN53OnWgsr907tR1Rw,5509
|
|
4
|
+
datalayer_cells/format.py,sha256=dwFKmRrCz5OW7iqDsBPxyTjcg5wea8KyxfnJeYoptus,9753
|
|
5
|
+
datalayer_cells/workbook.py,sha256=vPD8djBJKo5FgCiXSxwKW1PN_cAqvfIyvnGyCfNtsoY,13083
|
|
6
|
+
datalayer_cells-0.0.2.dist-info/METADATA,sha256=DxA60evXvkrPQSx_yNn_-Ke0btVf_WyRF2_oSVcPlLs,4002
|
|
7
|
+
datalayer_cells-0.0.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
datalayer_cells-0.0.2.dist-info/entry_points.txt,sha256=Kd3BrmhCPUOi686PQqDI6BnAkKUAtq4Kqf2kCcCalnU,113
|
|
9
|
+
datalayer_cells-0.0.2.dist-info/licenses/LICENSE,sha256=X75wtNZtai3-uHnGrGP9sHazj_5k3WRbaNaZzdzj6Bk,1077
|
|
10
|
+
datalayer_cells-0.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022-2026 Datalayer, Inc.
|
|
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.
|