llusen 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- llusen/__init__.py +40 -0
- llusen/_client.py +642 -0
- llusen/_frames.py +87 -0
- llusen/_values.py +202 -0
- llusen-0.1.0.dist-info/METADATA +198 -0
- llusen-0.1.0.dist-info/RECORD +9 -0
- llusen-0.1.0.dist-info/WHEEL +5 -0
- llusen-0.1.0.dist-info/licenses/LICENSE +21 -0
- llusen-0.1.0.dist-info/top_level.txt +1 -0
llusen/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Read and change the project open in llusen from Python.
|
|
2
|
+
|
|
3
|
+
import llusen
|
|
4
|
+
|
|
5
|
+
with llusen.connect() as project:
|
|
6
|
+
orders = project.table("orders")
|
|
7
|
+
frame = orders.to_pandas() # needs pandas
|
|
8
|
+
orders.add_column("total", "price * qty")
|
|
9
|
+
|
|
10
|
+
llusen must be running, with Tools > API Enabled on. The connection is
|
|
11
|
+
to this machine only, with the token llusen writes to its api.json: to
|
|
12
|
+
the llusen used last, or with `connect(project=...)` to the one that
|
|
13
|
+
has that project open.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from ._client import (
|
|
17
|
+
Chart,
|
|
18
|
+
LlusenError,
|
|
19
|
+
NotRunning,
|
|
20
|
+
Project,
|
|
21
|
+
Table,
|
|
22
|
+
connect,
|
|
23
|
+
find_project,
|
|
24
|
+
state_dir,
|
|
25
|
+
)
|
|
26
|
+
from ._values import TYPES, Fault
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Chart",
|
|
30
|
+
"Fault",
|
|
31
|
+
"LlusenError",
|
|
32
|
+
"NotRunning",
|
|
33
|
+
"Project",
|
|
34
|
+
"TYPES",
|
|
35
|
+
"Table",
|
|
36
|
+
"connect",
|
|
37
|
+
"find_project",
|
|
38
|
+
"state_dir",
|
|
39
|
+
]
|
|
40
|
+
__version__ = "0.1.0"
|
llusen/_client.py
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
"""The connection to llusen, and the project, tables and charts it
|
|
2
|
+
reaches."""
|
|
3
|
+
|
|
4
|
+
import contextlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Iterable
|
|
11
|
+
|
|
12
|
+
from . import _frames
|
|
13
|
+
from ._values import from_json, to_json
|
|
14
|
+
|
|
15
|
+
#: How many rows `Table.read` asks for at a time.
|
|
16
|
+
READ_CHUNK = 100_000
|
|
17
|
+
|
|
18
|
+
#: How long to wait for an answer, in seconds. llusen holds a call that
|
|
19
|
+
#: needs formulas worked out for up to five minutes before refusing it.
|
|
20
|
+
TIMEOUT = 360.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LlusenError(Exception):
|
|
24
|
+
"""A call llusen refused, or could not take. `code` is JSON-RPC's
|
|
25
|
+
error code; `at` is, for a formula, the (start, end) bytes of it the
|
|
26
|
+
problem is about."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
message: str,
|
|
31
|
+
code: int | None = None,
|
|
32
|
+
at: tuple[int, int] | None = None,
|
|
33
|
+
):
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.message = message
|
|
36
|
+
self.code = code
|
|
37
|
+
self.at = at
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NotRunning(LlusenError, ConnectionError):
|
|
41
|
+
"""llusen is not running, or its API is off (Tools > API Enabled)."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def state_dir(
|
|
45
|
+
platform: str | None = None, env: dict[str, str] | None = None
|
|
46
|
+
) -> Path | None:
|
|
47
|
+
"""The folder llusen keeps its own state in, where it writes
|
|
48
|
+
api.json: as `recent::state_dir` in the app works it out."""
|
|
49
|
+
platform = platform or sys.platform
|
|
50
|
+
env = os.environ if env is None else env
|
|
51
|
+
|
|
52
|
+
def var(name: str) -> str | None:
|
|
53
|
+
return env.get(name) or None
|
|
54
|
+
|
|
55
|
+
if platform.startswith("win"):
|
|
56
|
+
base = var("LOCALAPPDATA")
|
|
57
|
+
return Path(base) / "llusen" if base else None
|
|
58
|
+
if platform == "darwin":
|
|
59
|
+
home = var("HOME")
|
|
60
|
+
if not home:
|
|
61
|
+
return None
|
|
62
|
+
return Path(home) / "Library" / "Application Support" / "llusen"
|
|
63
|
+
state = var("XDG_STATE_HOME")
|
|
64
|
+
if state and Path(state).is_absolute():
|
|
65
|
+
return Path(state) / "llusen"
|
|
66
|
+
home = var("HOME")
|
|
67
|
+
return Path(home) / ".local" / "state" / "llusen" if home else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
NOT_RUNNING = (
|
|
71
|
+
"llusen's API is not on: start llusen, and choose Tools > API Enabled"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _same_file(a: Path, b: Path, platform: str | None = None) -> bool:
|
|
76
|
+
"""Whether two resolved paths name one file: ignoring case on Windows
|
|
77
|
+
and macOS, whose usual file systems do, as the app does."""
|
|
78
|
+
platform = platform or sys.platform
|
|
79
|
+
if platform.startswith("win") or platform == "darwin":
|
|
80
|
+
return str(a).casefold() == str(b).casefold()
|
|
81
|
+
return a == b
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def find_project(
|
|
85
|
+
project: str | os.PathLike, folder: Path | None = None
|
|
86
|
+
) -> tuple[int, str]:
|
|
87
|
+
"""The API port and token of the llusen that has `project` open,
|
|
88
|
+
from the files each llusen keeps in `instances` in llusen's state
|
|
89
|
+
folder (or `folder`). Raises NotRunning if no llusen has it open, or
|
|
90
|
+
if the one that has has its API off."""
|
|
91
|
+
wanted = Path(project).expanduser().resolve()
|
|
92
|
+
folder = folder if folder is not None else state_dir()
|
|
93
|
+
found = []
|
|
94
|
+
if folder is not None:
|
|
95
|
+
for file in (folder / "instances").glob("*.json"):
|
|
96
|
+
try:
|
|
97
|
+
info = json.loads(file.read_text(encoding="utf-8"))
|
|
98
|
+
except (OSError, ValueError):
|
|
99
|
+
continue
|
|
100
|
+
if not isinstance(info, dict) or not info.get("project"):
|
|
101
|
+
continue
|
|
102
|
+
if _same_file(Path(info["project"]), wanted):
|
|
103
|
+
found.append(info)
|
|
104
|
+
if not found:
|
|
105
|
+
raise NotRunning(f"no llusen has {wanted} open")
|
|
106
|
+
# Only one should; if a crash left another, take the one used last.
|
|
107
|
+
info = max(found, key=lambda info: info.get("used") or 0)
|
|
108
|
+
api = info.get("api")
|
|
109
|
+
if not api:
|
|
110
|
+
raise NotRunning(
|
|
111
|
+
f"{wanted.name} is open in llusen, but its API is off: "
|
|
112
|
+
"choose Tools > API Enabled in its window"
|
|
113
|
+
)
|
|
114
|
+
return api["port"], api["token"]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def connect(
|
|
118
|
+
port: int | None = None,
|
|
119
|
+
token: str | None = None,
|
|
120
|
+
info_file: str | os.PathLike | None = None,
|
|
121
|
+
timeout: float = TIMEOUT,
|
|
122
|
+
project: str | os.PathLike | None = None,
|
|
123
|
+
) -> "Project":
|
|
124
|
+
"""Connect to a llusen running on this machine. With `project`, the
|
|
125
|
+
llusen that has that project file open; otherwise the one used last.
|
|
126
|
+
The port and token are read from llusen's api.json (or `info_file`)
|
|
127
|
+
unless given."""
|
|
128
|
+
if project is not None and (port is None or token is None):
|
|
129
|
+
found_port, found_token = find_project(project)
|
|
130
|
+
port = found_port if port is None else port
|
|
131
|
+
token = found_token if token is None else token
|
|
132
|
+
if port is None or token is None:
|
|
133
|
+
path = Path(info_file) if info_file else None
|
|
134
|
+
if path is None:
|
|
135
|
+
folder = state_dir()
|
|
136
|
+
if folder is None:
|
|
137
|
+
raise NotRunning("cannot tell where llusen keeps api.json")
|
|
138
|
+
path = folder / "api.json"
|
|
139
|
+
try:
|
|
140
|
+
info = json.loads(path.read_text(encoding="utf-8"))
|
|
141
|
+
except FileNotFoundError:
|
|
142
|
+
raise NotRunning(NOT_RUNNING) from None
|
|
143
|
+
port = info["port"] if port is None else port
|
|
144
|
+
token = info["token"] if token is None else token
|
|
145
|
+
try:
|
|
146
|
+
sock = socket.create_connection(("127.0.0.1", port), timeout=timeout)
|
|
147
|
+
except ConnectionRefusedError:
|
|
148
|
+
# api.json left by a llusen that has gone.
|
|
149
|
+
raise NotRunning(NOT_RUNNING) from None
|
|
150
|
+
project = Project(sock)
|
|
151
|
+
try:
|
|
152
|
+
project.call("authenticate", token=token)
|
|
153
|
+
except BaseException:
|
|
154
|
+
project.close()
|
|
155
|
+
raise
|
|
156
|
+
return project
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class Project:
|
|
160
|
+
"""The project open in llusen. Use as a context manager, or call
|
|
161
|
+
`close`, to close the connection. One connection is for one thread
|
|
162
|
+
at a time."""
|
|
163
|
+
|
|
164
|
+
def __init__(self, sock: socket.socket):
|
|
165
|
+
self._socket = sock
|
|
166
|
+
self._reader = sock.makefile("r", encoding="utf-8", newline="\n")
|
|
167
|
+
self._next_id = 0
|
|
168
|
+
|
|
169
|
+
def __enter__(self) -> "Project":
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
def __exit__(self, *_: object) -> None:
|
|
173
|
+
self.close()
|
|
174
|
+
|
|
175
|
+
def close(self) -> None:
|
|
176
|
+
self._reader.close()
|
|
177
|
+
self._socket.close()
|
|
178
|
+
|
|
179
|
+
def call(self, method: str, **params: Any) -> Any:
|
|
180
|
+
"""Call `method` with `params` and give its result, raising
|
|
181
|
+
LlusenError if llusen refuses it. Params that are None are left
|
|
182
|
+
out."""
|
|
183
|
+
self._next_id += 1
|
|
184
|
+
message = {
|
|
185
|
+
"jsonrpc": "2.0",
|
|
186
|
+
"id": self._next_id,
|
|
187
|
+
"method": method,
|
|
188
|
+
"params": {k: v for k, v in params.items() if v is not None},
|
|
189
|
+
}
|
|
190
|
+
line = json.dumps(message, ensure_ascii=False, allow_nan=False)
|
|
191
|
+
try:
|
|
192
|
+
self._socket.sendall(line.encode("utf-8") + b"\n")
|
|
193
|
+
answer = self._reader.readline()
|
|
194
|
+
except (ConnectionError, OSError) as error:
|
|
195
|
+
raise NotRunning(f"the connection to llusen closed: {error}")
|
|
196
|
+
if not answer:
|
|
197
|
+
raise NotRunning("llusen closed the connection")
|
|
198
|
+
answer = json.loads(answer)
|
|
199
|
+
if "error" in answer:
|
|
200
|
+
error = answer["error"]
|
|
201
|
+
at = (error.get("data") or {}).get("at")
|
|
202
|
+
raise LlusenError(
|
|
203
|
+
error.get("message", "refused"),
|
|
204
|
+
error.get("code"),
|
|
205
|
+
tuple(at) if at else None,
|
|
206
|
+
)
|
|
207
|
+
return answer.get("result")
|
|
208
|
+
|
|
209
|
+
# Reading.
|
|
210
|
+
|
|
211
|
+
def describe(self) -> dict:
|
|
212
|
+
"""Every table, chart and group table, as llusen describes them:
|
|
213
|
+
{"tables": [{name, kind, rows, columns, summary, ...}]}."""
|
|
214
|
+
return self.call("project")
|
|
215
|
+
|
|
216
|
+
def table_names(self) -> list[str]:
|
|
217
|
+
return [table["name"] for table in self.describe()["tables"]]
|
|
218
|
+
|
|
219
|
+
def table(self, name: str) -> "Table":
|
|
220
|
+
"""Table (or group table) `name`. Nothing is read until asked."""
|
|
221
|
+
return Table(self, name)
|
|
222
|
+
|
|
223
|
+
def chart(self, name: str) -> "Chart":
|
|
224
|
+
return Chart(self, name)
|
|
225
|
+
|
|
226
|
+
# Making tables.
|
|
227
|
+
|
|
228
|
+
def new_table(
|
|
229
|
+
self,
|
|
230
|
+
name: str,
|
|
231
|
+
data: Any,
|
|
232
|
+
types: dict[str, str] | None = None,
|
|
233
|
+
) -> "Table":
|
|
234
|
+
"""A new table named `name` holding `data`: a DataFrame, or a
|
|
235
|
+
dict of column names to lists of values (all as long). Dates,
|
|
236
|
+
datetimes, times and durations are kept as such; other types
|
|
237
|
+
are worked out from the values unless named in `types` ("int",
|
|
238
|
+
"float", "bool", "text", "date", "datetime", "time" or
|
|
239
|
+
"duration")."""
|
|
240
|
+
columns = _frames.columns_of(data, types)
|
|
241
|
+
self.call("new_table", name=name, columns=columns)
|
|
242
|
+
return Table(self, name)
|
|
243
|
+
|
|
244
|
+
def new_group_table(self, name: str, grouping: str) -> "Table":
|
|
245
|
+
"""A group table, grouped as `grouping` says, e.g.
|
|
246
|
+
'table("orders").group(r => ({ Item: r.item }))'."""
|
|
247
|
+
self.call("new_group_table", name=name, grouping=grouping)
|
|
248
|
+
return Table(self, name)
|
|
249
|
+
|
|
250
|
+
def new_chart(
|
|
251
|
+
self,
|
|
252
|
+
name: str,
|
|
253
|
+
table: str,
|
|
254
|
+
x: str,
|
|
255
|
+
ys: str | Iterable[str],
|
|
256
|
+
kind: str | None = None,
|
|
257
|
+
) -> "Chart":
|
|
258
|
+
"""A chart of `table`: column `x` along the bottom, a series for
|
|
259
|
+
each of `ys`, as "bar", "line" or "doughnut" (one of `ys` only;
|
|
260
|
+
else what suits `x`)."""
|
|
261
|
+
ys = [ys] if isinstance(ys, str) else list(ys)
|
|
262
|
+
self.call(
|
|
263
|
+
"new_chart", name=name, table=table, x=x, ys=ys, kind=kind
|
|
264
|
+
)
|
|
265
|
+
return Chart(self, name)
|
|
266
|
+
|
|
267
|
+
# Files.
|
|
268
|
+
|
|
269
|
+
def export(
|
|
270
|
+
self,
|
|
271
|
+
path: str | os.PathLike,
|
|
272
|
+
format: str | None = None,
|
|
273
|
+
tables: Iterable[str] | None = None,
|
|
274
|
+
) -> dict:
|
|
275
|
+
"""Write every table (or those named in `tables`) to a workbook
|
|
276
|
+
at `path`, a sheet each, with their summary values and formats;
|
|
277
|
+
charts are left out. `format` is "xlsx" or "ods", else the
|
|
278
|
+
path's extension says. The file is written by llusen, so the
|
|
279
|
+
path is on the machine llusen runs on; a relative path is taken
|
|
280
|
+
from this script's folder. A file there is replaced. Gives
|
|
281
|
+
{"path", "rows", "sheets"} once it is written."""
|
|
282
|
+
return self.call(
|
|
283
|
+
"export",
|
|
284
|
+
path=_absolute(path),
|
|
285
|
+
format=format,
|
|
286
|
+
tables=None if tables is None else list(tables),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class _Named:
|
|
291
|
+
"""A table or chart of the project, by name."""
|
|
292
|
+
|
|
293
|
+
def __init__(self, project: Project, name: str):
|
|
294
|
+
self.project = project
|
|
295
|
+
self.name = name
|
|
296
|
+
|
|
297
|
+
def __repr__(self) -> str:
|
|
298
|
+
return f"{type(self).__name__}({self.name!r})"
|
|
299
|
+
|
|
300
|
+
def describe(self) -> dict:
|
|
301
|
+
"""How llusen describes it now (see `Project.describe`)."""
|
|
302
|
+
for table in self.project.describe()["tables"]:
|
|
303
|
+
if table["name"] == self.name:
|
|
304
|
+
return table
|
|
305
|
+
raise LlusenError(f"there is no table {self.name}")
|
|
306
|
+
|
|
307
|
+
def rename(self, name: str) -> None:
|
|
308
|
+
"""Rename it; formulas, group tables and charts naming it
|
|
309
|
+
follow."""
|
|
310
|
+
self.project.call("rename_table", table=self.name, name=name)
|
|
311
|
+
self.name = name.strip()
|
|
312
|
+
|
|
313
|
+
def delete(self) -> None:
|
|
314
|
+
self.project.call("delete_table", table=self.name)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class Table(_Named):
|
|
318
|
+
"""A table or group table of the project. Rows are counted in the
|
|
319
|
+
order they were loaded or added, whatever the sort and filters in
|
|
320
|
+
llusen show."""
|
|
321
|
+
|
|
322
|
+
def _call(self, method: str, **params: Any) -> Any:
|
|
323
|
+
return self.project.call(method, table=self.name, **params)
|
|
324
|
+
|
|
325
|
+
@property
|
|
326
|
+
def columns(self) -> list[str]:
|
|
327
|
+
return [column["name"] for column in self.describe()["columns"]]
|
|
328
|
+
|
|
329
|
+
def __len__(self) -> int:
|
|
330
|
+
return self.describe()["rows"]
|
|
331
|
+
|
|
332
|
+
def read(
|
|
333
|
+
self,
|
|
334
|
+
columns: Iterable[str] | None = None,
|
|
335
|
+
start: int = 0,
|
|
336
|
+
count: int | None = None,
|
|
337
|
+
) -> dict[str, list]:
|
|
338
|
+
"""The values of `columns` (all if None), `count` rows from row
|
|
339
|
+
`start` (to the end if None), as {name: [values]}. Dates are
|
|
340
|
+
datetime.date, datetimes datetime.datetime, faults Fault."""
|
|
341
|
+
return {
|
|
342
|
+
column["name"]: column["values"]
|
|
343
|
+
for column in self._read(columns, start, count)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
def export(
|
|
347
|
+
self, path: str | os.PathLike, format: str | None = None
|
|
348
|
+
) -> dict:
|
|
349
|
+
"""Write the table to `path`: as CSV, or as a workbook of this
|
|
350
|
+
one table if `format` (or the path's extension) is "xlsx" or
|
|
351
|
+
"ods". Rows are written in the order llusen shows them. See
|
|
352
|
+
`Project.export`. Gives {"path", "rows"} once it is written."""
|
|
353
|
+
path = _absolute(path)
|
|
354
|
+
kind = format or Path(path).suffix.lower().lstrip(".")
|
|
355
|
+
if kind in ("xlsx", "ods"):
|
|
356
|
+
return self.project.export(path, kind, [self.name])
|
|
357
|
+
return self._call("export", path=path, format=format or "csv")
|
|
358
|
+
|
|
359
|
+
def to_pandas(self, columns: Iterable[str] | None = None) -> Any:
|
|
360
|
+
"""The table as a pandas DataFrame (see `_frames.to_frame`)."""
|
|
361
|
+
return _frames.to_frame(self._read(columns, 0, None))
|
|
362
|
+
|
|
363
|
+
def _read(
|
|
364
|
+
self,
|
|
365
|
+
columns: Iterable[str] | None,
|
|
366
|
+
start: int,
|
|
367
|
+
count: int | None,
|
|
368
|
+
) -> list[dict]:
|
|
369
|
+
"""Read in pieces of READ_CHUNK rows: [{name, type, values}],
|
|
370
|
+
values turned into Python's."""
|
|
371
|
+
names = None if columns is None else list(columns)
|
|
372
|
+
read: list[dict] | None = None
|
|
373
|
+
at = start
|
|
374
|
+
while True:
|
|
375
|
+
left = None if count is None else start + count - at
|
|
376
|
+
piece = READ_CHUNK if left is None else min(left, READ_CHUNK)
|
|
377
|
+
result = self._call(
|
|
378
|
+
"read", columns=names, start=at, count=piece
|
|
379
|
+
)
|
|
380
|
+
if not result["columns"]:
|
|
381
|
+
return []
|
|
382
|
+
if read is None:
|
|
383
|
+
read = [
|
|
384
|
+
{"name": c["name"], "type": c["type"], "values": []}
|
|
385
|
+
for c in result["columns"]
|
|
386
|
+
]
|
|
387
|
+
for into, column in zip(read, result["columns"]):
|
|
388
|
+
kind = column["type"]
|
|
389
|
+
into["values"].extend(
|
|
390
|
+
from_json(value, kind) for value in column["values"]
|
|
391
|
+
)
|
|
392
|
+
got = len(result["columns"][0]["values"])
|
|
393
|
+
at += got
|
|
394
|
+
done = at >= result["rows"] or got == 0
|
|
395
|
+
if done or (count is not None and at >= start + count):
|
|
396
|
+
return read
|
|
397
|
+
|
|
398
|
+
# Columns.
|
|
399
|
+
|
|
400
|
+
def add_column(
|
|
401
|
+
self,
|
|
402
|
+
name: str,
|
|
403
|
+
formula: str | None = None,
|
|
404
|
+
values: Iterable[Any] | None = None,
|
|
405
|
+
type: str | None = None,
|
|
406
|
+
) -> None:
|
|
407
|
+
"""Add a column at the end: with `formula`, or holding `values`
|
|
408
|
+
(one per row; nulls if None) of `type` (else worked out)."""
|
|
409
|
+
listed = None if values is None else list(values)
|
|
410
|
+
if listed is not None and type is None:
|
|
411
|
+
from ._values import type_of
|
|
412
|
+
|
|
413
|
+
type = type_of(listed)
|
|
414
|
+
self._call(
|
|
415
|
+
"add_column",
|
|
416
|
+
name=name,
|
|
417
|
+
formula=formula,
|
|
418
|
+
values=None if listed is None else [to_json(v) for v in listed],
|
|
419
|
+
type=type,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
def set_formula(self, column: str, formula: str) -> None:
|
|
423
|
+
self._call("set_formula", column=column, formula=formula)
|
|
424
|
+
|
|
425
|
+
def remove_formula(self, column: str) -> None:
|
|
426
|
+
"""Take the formula away: the column keeps its values."""
|
|
427
|
+
self._call("remove_formula", column=column)
|
|
428
|
+
|
|
429
|
+
def rename_column(self, column: str, name: str) -> None:
|
|
430
|
+
"""Rename `column`; formulas reading it follow."""
|
|
431
|
+
self._call("rename_column", column=column, name=name)
|
|
432
|
+
|
|
433
|
+
def delete_columns(self, *columns: str) -> None:
|
|
434
|
+
self._call("delete_columns", columns=list(columns))
|
|
435
|
+
|
|
436
|
+
def set_format(self, column: str, format: str | None = None) -> None:
|
|
437
|
+
"""Show `column` in `format`, e.g. 'currency("£", 2)', or as its
|
|
438
|
+
values are if None."""
|
|
439
|
+
self._call("set_format", column=column, format=format or "")
|
|
440
|
+
|
|
441
|
+
# Rows.
|
|
442
|
+
|
|
443
|
+
def append_rows(self, data: Any) -> int:
|
|
444
|
+
"""Add rows after the last: `data` is a DataFrame, or a dict of
|
|
445
|
+
column names to lists of values (all as long). Columns left out
|
|
446
|
+
are null. Gives the first new row."""
|
|
447
|
+
columns = _frames.columns_of(data)
|
|
448
|
+
values = {column["name"]: column["values"] for column in columns}
|
|
449
|
+
return self._call("append_rows", columns=values)
|
|
450
|
+
|
|
451
|
+
def lock(
|
|
452
|
+
self,
|
|
453
|
+
clear: bool = False,
|
|
454
|
+
columns: dict[str, str] | Iterable[tuple[str, str]] | None = None,
|
|
455
|
+
) -> dict | None:
|
|
456
|
+
"""Lock the table to add live rows to it with `insert_rows`: any
|
|
457
|
+
sort and filters in llusen are lifted, and until `unlock` (or
|
|
458
|
+
this connection closes), no one else can change it.
|
|
459
|
+
|
|
460
|
+
`columns` are the columns the rows have, by name, each with its
|
|
461
|
+
type ("int", "float", "bool", "text", "date", "datetime", "time"
|
|
462
|
+
or "duration"). With
|
|
463
|
+
`clear`, the table is emptied for a new run and its plain columns
|
|
464
|
+
made those, in that order (formula columns stay, after them),
|
|
465
|
+
with no undo step; this gives what changed: {cleared, added,
|
|
466
|
+
retyped, removed}. Without, they are checked against the table's,
|
|
467
|
+
and a difference refuses the lock."""
|
|
468
|
+
if clear and columns is None:
|
|
469
|
+
raise ValueError("clear needs the columns, each with its type")
|
|
470
|
+
listed = None
|
|
471
|
+
if columns is not None:
|
|
472
|
+
pairs = columns.items() if isinstance(columns, dict) else columns
|
|
473
|
+
listed = [{"name": name, "type": kind} for name, kind in pairs]
|
|
474
|
+
return self._call(
|
|
475
|
+
"lock_table", clear=clear or None, columns=listed
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
def insert_rows(self, data: Any) -> int:
|
|
479
|
+
"""Add rows at the end of the table, which this connection has
|
|
480
|
+
locked, as `append_rows` takes them, with no undo step. llusen
|
|
481
|
+
checks them and answers at once, with the row the first will be
|
|
482
|
+
at, then puts them in as soon as the table is free."""
|
|
483
|
+
columns = _frames.columns_of(data)
|
|
484
|
+
values = {column["name"]: column["values"] for column in columns}
|
|
485
|
+
return self._call("insert_rows", columns=values)
|
|
486
|
+
|
|
487
|
+
def unlock(self) -> None:
|
|
488
|
+
"""Unlock the table, once the rows sent have gone in."""
|
|
489
|
+
self._call("unlock_table")
|
|
490
|
+
|
|
491
|
+
@contextlib.contextmanager
|
|
492
|
+
def locked(
|
|
493
|
+
self,
|
|
494
|
+
clear: bool = False,
|
|
495
|
+
columns: dict[str, str] | Iterable[tuple[str, str]] | None = None,
|
|
496
|
+
):
|
|
497
|
+
"""The table locked for the `with` block (as `lock` takes
|
|
498
|
+
`clear` and `columns`), and unlocked after, whatever happens in
|
|
499
|
+
it:
|
|
500
|
+
|
|
501
|
+
with table.locked(clear=True, columns={"price": "float"}):
|
|
502
|
+
table.insert_rows({"price": [101.5]})
|
|
503
|
+
"""
|
|
504
|
+
self.lock(clear, columns)
|
|
505
|
+
try:
|
|
506
|
+
yield self
|
|
507
|
+
finally:
|
|
508
|
+
self.unlock()
|
|
509
|
+
|
|
510
|
+
@property
|
|
511
|
+
def is_locked(self) -> bool:
|
|
512
|
+
"""Whether a script has the table locked to add live rows."""
|
|
513
|
+
return self.describe().get("locked", False)
|
|
514
|
+
|
|
515
|
+
def set_cells(
|
|
516
|
+
self, column: str, values: Iterable[Any], start: int = 0
|
|
517
|
+
) -> int:
|
|
518
|
+
"""Set `column` from row `start` on to `values`, all or none.
|
|
519
|
+
Gives how many were set."""
|
|
520
|
+
values = [to_json(value) for value in values]
|
|
521
|
+
return self._call(
|
|
522
|
+
"set_cells", column=column, start=start, values=values
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
def change_case(self, column: str, case: str) -> int:
|
|
526
|
+
"""Put every cell of text column `column` in `case`: "upper",
|
|
527
|
+
"lower", "capitalize" (the first letter a capital, the rest
|
|
528
|
+
small) or "title" (each word's first letter a capital). Gives how
|
|
529
|
+
many cells changed."""
|
|
530
|
+
return self._call("change_case", column=column, case=case)
|
|
531
|
+
|
|
532
|
+
# Filters.
|
|
533
|
+
|
|
534
|
+
def set_filter(self, column: str, formula: str | None = None) -> int:
|
|
535
|
+
"""Filter on `column` by `formula` (`value` is the column's cell,
|
|
536
|
+
e.g. "value > 5"), or take its filter away if None. Gives how
|
|
537
|
+
many rows show."""
|
|
538
|
+
return self._call("set_filter", column=column, formula=formula or "")
|
|
539
|
+
|
|
540
|
+
def clear_filters(self) -> None:
|
|
541
|
+
self._call("clear_filters")
|
|
542
|
+
|
|
543
|
+
# Summary values.
|
|
544
|
+
|
|
545
|
+
def summary(self) -> dict[str, Any]:
|
|
546
|
+
"""The summary values, by name, worked out."""
|
|
547
|
+
values = {}
|
|
548
|
+
for value in self.describe()["summary"]:
|
|
549
|
+
values[value["name"]] = from_json(
|
|
550
|
+
value["value"], value.get("type", "")
|
|
551
|
+
)
|
|
552
|
+
return values
|
|
553
|
+
|
|
554
|
+
def add_summary(
|
|
555
|
+
self,
|
|
556
|
+
formula: str,
|
|
557
|
+
name: str | None = None,
|
|
558
|
+
format: str | None = None,
|
|
559
|
+
) -> str:
|
|
560
|
+
"""Add a summary value (value1 and so on if not named). Gives its
|
|
561
|
+
name."""
|
|
562
|
+
return self._call(
|
|
563
|
+
"add_summary", formula=formula, name=name, format=format
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
def set_summary_formula(self, name: str, formula: str) -> None:
|
|
567
|
+
self._call("set_summary_formula", name=name, formula=formula)
|
|
568
|
+
|
|
569
|
+
def set_summary_format(
|
|
570
|
+
self, name: str, format: str | None = None
|
|
571
|
+
) -> None:
|
|
572
|
+
self._call("set_summary_format", name=name, format=format or "")
|
|
573
|
+
|
|
574
|
+
def delete_summary(self, name: str) -> None:
|
|
575
|
+
self._call("delete_summary", name=name)
|
|
576
|
+
|
|
577
|
+
# Group tables.
|
|
578
|
+
|
|
579
|
+
def set_grouping(self, grouping: str) -> None:
|
|
580
|
+
"""Group this group table as `grouping` says."""
|
|
581
|
+
self._call("set_grouping", grouping=grouping)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
class Chart(_Named):
|
|
585
|
+
"""A chart of the project."""
|
|
586
|
+
|
|
587
|
+
def notes(self) -> list[dict]:
|
|
588
|
+
"""The notes: [{id, text, anchors: [{series, x}]}]."""
|
|
589
|
+
return self.describe()["notes"]
|
|
590
|
+
|
|
591
|
+
def add_note(
|
|
592
|
+
self,
|
|
593
|
+
text: str,
|
|
594
|
+
anchors: Iterable[tuple[str, Any]] = (),
|
|
595
|
+
) -> int:
|
|
596
|
+
"""Add a note, attached to the points `anchors` ((series, x)
|
|
597
|
+
pairs: x as the column along the bottom holds it), or to the
|
|
598
|
+
chart as a whole. A series of "" means every series; the
|
|
599
|
+
anchors share one series. Gives its id."""
|
|
600
|
+
points = [
|
|
601
|
+
{"series": series, "x": _anchor_x(x)} for series, x in anchors
|
|
602
|
+
]
|
|
603
|
+
return self.project.call(
|
|
604
|
+
"add_note", chart=self.name, text=text, anchors=points
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
def set_settings(self, kind: str | None = None, **settings: bool) -> bool:
|
|
608
|
+
"""Change the chart's kind ("bar", "line" or "doughnut") and its
|
|
609
|
+
settings menu: smooth, line_fill, line_from_zero, horizontal,
|
|
610
|
+
bar_from_zero, pie (each True or False). What is left out stays
|
|
611
|
+
as it is. Gives whether anything changed."""
|
|
612
|
+
return self.project.call(
|
|
613
|
+
"set_chart_settings", chart=self.name, kind=kind, **settings
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
def hidden_series(self) -> list[str]:
|
|
617
|
+
"""The series hidden from the chart's key."""
|
|
618
|
+
return self.describe()["hidden"]
|
|
619
|
+
|
|
620
|
+
def set_hidden_series(self, series: Iterable[str]) -> bool:
|
|
621
|
+
"""Hide `series` (column names) from the chart's key, showing
|
|
622
|
+
the rest; at least one stays shown. Gives whether anything
|
|
623
|
+
changed."""
|
|
624
|
+
return self.project.call(
|
|
625
|
+
"set_hidden_series", chart=self.name, series=list(series)
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _absolute(path: str | os.PathLike) -> str:
|
|
630
|
+
"""`path` made whole, from `~` or this script's folder: llusen's
|
|
631
|
+
folder is not the script's."""
|
|
632
|
+
return os.path.abspath(os.path.expanduser(os.fspath(path)))
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _anchor_x(x: Any) -> str:
|
|
636
|
+
"""A place along a chart's bottom as llusen writes it: a date as
|
|
637
|
+
2026-10-21, a datetime in ISO form, a time as 14:05:00, a duration
|
|
638
|
+
as 36:15:00, a number or text as it is."""
|
|
639
|
+
value = to_json(x)
|
|
640
|
+
if isinstance(value, bool):
|
|
641
|
+
return "TRUE" if value else "FALSE"
|
|
642
|
+
return str(value)
|
llusen/_frames.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Columns of values to and from pandas, which is optional: nothing here
|
|
2
|
+
imports it until a DataFrame is asked for or given."""
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ._values import to_json, type_of
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_frame(data: Any) -> bool:
|
|
10
|
+
"""Whether `data` is a pandas DataFrame (without importing pandas)."""
|
|
11
|
+
return type(data).__name__ == "DataFrame" and hasattr(data, "columns")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def columns_of(data: Any, types: dict[str, str] | None = None) -> list[dict]:
|
|
15
|
+
"""`data` (a DataFrame, or a mapping of column names to lists of
|
|
16
|
+
values, in order) as llusen's columns: [{name, values, type?}]. A
|
|
17
|
+
column's type is `types[name]` if given; else datetime, date, time
|
|
18
|
+
or duration if its values are; else llusen works it out (numbers,
|
|
19
|
+
booleans, text)."""
|
|
20
|
+
types = types or {}
|
|
21
|
+
if is_frame(data):
|
|
22
|
+
pairs = [
|
|
23
|
+
(str(name), _frame_column(data[name])) for name in data.columns
|
|
24
|
+
]
|
|
25
|
+
elif hasattr(data, "items"):
|
|
26
|
+
pairs = [(str(name), list(values)) for name, values in data.items()]
|
|
27
|
+
else:
|
|
28
|
+
raise TypeError(
|
|
29
|
+
"give the columns as a DataFrame, or a dict of column names to "
|
|
30
|
+
"lists of values"
|
|
31
|
+
)
|
|
32
|
+
columns = []
|
|
33
|
+
for name, values in pairs:
|
|
34
|
+
kind = types.get(name) or type_of(values)
|
|
35
|
+
sent = [to_json(value) for value in values]
|
|
36
|
+
column = {"name": name, "values": sent}
|
|
37
|
+
if kind is not None:
|
|
38
|
+
column["type"] = kind
|
|
39
|
+
columns.append(column)
|
|
40
|
+
return columns
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _frame_column(series: Any) -> list:
|
|
44
|
+
"""A DataFrame's column as a list of plain values, missing ones as
|
|
45
|
+
None."""
|
|
46
|
+
missing = series.isna().tolist()
|
|
47
|
+
values = series.tolist()
|
|
48
|
+
return [None if gone else value for value, gone in zip(values, missing)]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def to_frame(columns: list[dict]) -> Any:
|
|
52
|
+
"""llusen's columns, read and turned into Python values, as a
|
|
53
|
+
DataFrame: dates and datetimes as datetime64, durations as
|
|
54
|
+
timedelta64, times as datetime.time objects, whole numbers as int64
|
|
55
|
+
(Int64 if any is null), booleans as bool (boolean if any is null).
|
|
56
|
+
Faults are missing values."""
|
|
57
|
+
import pandas as pd
|
|
58
|
+
|
|
59
|
+
from ._values import Fault
|
|
60
|
+
|
|
61
|
+
frame = {}
|
|
62
|
+
for column in columns:
|
|
63
|
+
kind = column["type"]
|
|
64
|
+
values = [
|
|
65
|
+
None if isinstance(value, Fault) else value
|
|
66
|
+
for value in column["values"]
|
|
67
|
+
]
|
|
68
|
+
has_null = any(value is None for value in values)
|
|
69
|
+
if kind in ("date", "datetime"):
|
|
70
|
+
# A UTC column's values end in Z: they stay aware, in UTC.
|
|
71
|
+
aware = any(
|
|
72
|
+
getattr(value, "tzinfo", None) is not None for value in values
|
|
73
|
+
)
|
|
74
|
+
series = pd.Series(pd.to_datetime(values, utc=aware))
|
|
75
|
+
elif kind == "duration":
|
|
76
|
+
series = pd.Series(pd.to_timedelta(values))
|
|
77
|
+
elif kind == "int":
|
|
78
|
+
series = pd.Series(values, dtype="Int64" if has_null else "int64")
|
|
79
|
+
elif kind == "float":
|
|
80
|
+
series = pd.Series(values, dtype="float64")
|
|
81
|
+
elif kind == "bool":
|
|
82
|
+
kind = "boolean" if has_null else "bool"
|
|
83
|
+
series = pd.Series(values, dtype=kind)
|
|
84
|
+
else:
|
|
85
|
+
series = pd.Series(values, dtype="object")
|
|
86
|
+
frame[column["name"]] = series
|
|
87
|
+
return pd.DataFrame(frame)
|
llusen/_values.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Values between Python and llusen's JSON.
|
|
2
|
+
|
|
3
|
+
llusen sends whole numbers and decimals as JSON numbers, booleans and
|
|
4
|
+
text as themselves, null as null, a date as "2026-10-21", a datetime
|
|
5
|
+
as ISO 8601 ("2026-10-21T09:30:00", with a Z when its column is UTC),
|
|
6
|
+
a time of day as "14:05" (seconds and a fraction when it has them), a
|
|
7
|
+
duration as a spreadsheet shows one ("36:15:00", "-0:30"), and a cell
|
|
8
|
+
a formula could not work out as {"fault": "#DIV0"}.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import datetime as dt
|
|
12
|
+
import math
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
TYPES = (
|
|
16
|
+
"int",
|
|
17
|
+
"float",
|
|
18
|
+
"bool",
|
|
19
|
+
"text",
|
|
20
|
+
"date",
|
|
21
|
+
"datetime",
|
|
22
|
+
"time",
|
|
23
|
+
"duration",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Fault:
|
|
28
|
+
"""A cell a formula could not work out: #DIV0, #OVERFLOW, #TYPE,
|
|
29
|
+
#DATE or #MANY. Equal to another fault with the same tag."""
|
|
30
|
+
|
|
31
|
+
__slots__ = ("tag",)
|
|
32
|
+
|
|
33
|
+
def __init__(self, tag: str):
|
|
34
|
+
self.tag = tag
|
|
35
|
+
|
|
36
|
+
def __eq__(self, other: object) -> bool:
|
|
37
|
+
return isinstance(other, Fault) and other.tag == self.tag
|
|
38
|
+
|
|
39
|
+
def __hash__(self) -> int:
|
|
40
|
+
return hash(self.tag)
|
|
41
|
+
|
|
42
|
+
def __repr__(self) -> str:
|
|
43
|
+
return f"Fault({self.tag!r})"
|
|
44
|
+
|
|
45
|
+
def __str__(self) -> str:
|
|
46
|
+
return self.tag
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def from_json(value: Any, kind: str) -> Any:
|
|
50
|
+
"""A value llusen sent, for a column of type `kind`, as Python's:
|
|
51
|
+
a date as datetime.date, a datetime as datetime.datetime (aware,
|
|
52
|
+
in UTC, if it ends in Z), a time as datetime.time, a duration as
|
|
53
|
+
datetime.timedelta, a fault as a Fault."""
|
|
54
|
+
if value is None:
|
|
55
|
+
return None
|
|
56
|
+
if isinstance(value, dict):
|
|
57
|
+
return Fault(value.get("fault", "#?"))
|
|
58
|
+
if kind == "date":
|
|
59
|
+
return dt.date.fromisoformat(value)
|
|
60
|
+
if kind == "datetime":
|
|
61
|
+
return dt.datetime.fromisoformat(value)
|
|
62
|
+
if kind == "time":
|
|
63
|
+
return dt.time.fromisoformat(value)
|
|
64
|
+
if kind == "duration":
|
|
65
|
+
return parse_duration(value)
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_duration(text: str) -> dt.timedelta:
|
|
70
|
+
"""A duration as llusen writes it, hours:minutes with seconds and a
|
|
71
|
+
fraction if it has them ("36:15", "-0:00:01.5"), as a timedelta."""
|
|
72
|
+
negative = text.startswith("-")
|
|
73
|
+
parts = text.lstrip("-").split(":")
|
|
74
|
+
if len(parts) not in (2, 3):
|
|
75
|
+
raise ValueError(f"{text!r} is not a duration (hours:MM)")
|
|
76
|
+
whole, _, fraction = (parts[2] if len(parts) == 3 else "0").partition(
|
|
77
|
+
"."
|
|
78
|
+
)
|
|
79
|
+
micros = int((fraction + "000000")[:6]) if fraction else 0
|
|
80
|
+
length = dt.timedelta(
|
|
81
|
+
hours=int(parts[0]),
|
|
82
|
+
minutes=int(parts[1]),
|
|
83
|
+
seconds=int(whole),
|
|
84
|
+
microseconds=micros,
|
|
85
|
+
)
|
|
86
|
+
return -length if negative else length
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def format_duration(length: dt.timedelta) -> str:
|
|
90
|
+
"""A timedelta as llusen takes a duration: hours:MM:SS, with a
|
|
91
|
+
fraction if it has one and a - if it is negative ("-36:15:00")."""
|
|
92
|
+
micros = (
|
|
93
|
+
length.days * 86_400 + length.seconds
|
|
94
|
+
) * 1_000_000 + length.microseconds
|
|
95
|
+
sign = "-" if micros < 0 else ""
|
|
96
|
+
seconds, fraction = divmod(abs(micros), 1_000_000)
|
|
97
|
+
minutes, seconds = divmod(seconds, 60)
|
|
98
|
+
hours, minutes = divmod(minutes, 60)
|
|
99
|
+
text = f"{sign}{hours}:{minutes:02}:{seconds:02}"
|
|
100
|
+
if fraction:
|
|
101
|
+
text += f".{fraction:06}".rstrip("0")
|
|
102
|
+
return text
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def to_json(value: Any) -> Any:
|
|
106
|
+
"""A Python value as llusen takes it. NaN, NaT and pandas' NA are
|
|
107
|
+
null; numpy's numbers are numbers; a naive datetime is sent as it
|
|
108
|
+
is, and an aware one in UTC with a Z; a time of day and a duration
|
|
109
|
+
(timedelta, pandas' Timedelta, numpy's timedelta64) as text."""
|
|
110
|
+
if value is None:
|
|
111
|
+
return None
|
|
112
|
+
# numpy scalars (numpy.int64, numpy.bool_, numpy.datetime64...) and
|
|
113
|
+
# pandas' Timestamp, without importing either.
|
|
114
|
+
kind = type(value).__name__
|
|
115
|
+
if kind in ("NaTType", "NAType"):
|
|
116
|
+
return None
|
|
117
|
+
if kind == "Timestamp":
|
|
118
|
+
value = value.to_pydatetime()
|
|
119
|
+
elif kind == "datetime64":
|
|
120
|
+
return to_json(_numpy_datetime(value))
|
|
121
|
+
elif kind == "Timedelta":
|
|
122
|
+
# Its nanoseconds are dropped: llusen keeps microseconds.
|
|
123
|
+
value = value.to_pytimedelta()
|
|
124
|
+
elif kind == "timedelta64":
|
|
125
|
+
return to_json(_numpy_timedelta(value))
|
|
126
|
+
elif not isinstance(
|
|
127
|
+
value, (bool, int, float, str, dt.date, dt.time, dt.timedelta)
|
|
128
|
+
) and hasattr(
|
|
129
|
+
value, "item"
|
|
130
|
+
):
|
|
131
|
+
value = value.item()
|
|
132
|
+
if isinstance(value, bool):
|
|
133
|
+
return value
|
|
134
|
+
if isinstance(value, float):
|
|
135
|
+
return None if math.isnan(value) or math.isinf(value) else value
|
|
136
|
+
if isinstance(value, (int, str)):
|
|
137
|
+
return value
|
|
138
|
+
# datetime before date: a datetime is a date too.
|
|
139
|
+
if isinstance(value, dt.datetime):
|
|
140
|
+
if value.tzinfo is not None:
|
|
141
|
+
utc = value.astimezone(dt.timezone.utc).replace(tzinfo=None)
|
|
142
|
+
return utc.isoformat() + "Z"
|
|
143
|
+
return value.isoformat()
|
|
144
|
+
if isinstance(value, dt.date):
|
|
145
|
+
return value.isoformat()
|
|
146
|
+
if isinstance(value, dt.time):
|
|
147
|
+
if value.tzinfo is not None:
|
|
148
|
+
raise TypeError(
|
|
149
|
+
"llusen's times of day have no time zone: give "
|
|
150
|
+
f"{value} without one"
|
|
151
|
+
)
|
|
152
|
+
return value.isoformat()
|
|
153
|
+
if isinstance(value, dt.timedelta):
|
|
154
|
+
return format_duration(value)
|
|
155
|
+
raise TypeError(
|
|
156
|
+
f"llusen cannot hold {kind} values: use numbers, booleans, text, "
|
|
157
|
+
"dates, datetimes, times or durations"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _numpy_datetime(value: Any) -> Any:
|
|
162
|
+
"""A numpy.datetime64 as a datetime.datetime, or None for NaT."""
|
|
163
|
+
if str(value) == "NaT":
|
|
164
|
+
return None
|
|
165
|
+
nanos = int(value.astype("datetime64[ns]").astype("int64"))
|
|
166
|
+
return dt.datetime(1970, 1, 1) + dt.timedelta(microseconds=nanos // 1000)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _numpy_timedelta(value: Any) -> Any:
|
|
170
|
+
"""A numpy.timedelta64 as a datetime.timedelta, or None for NaT."""
|
|
171
|
+
if str(value) == "NaT":
|
|
172
|
+
return None
|
|
173
|
+
nanos = int(value.astype("timedelta64[ns]").astype("int64"))
|
|
174
|
+
# Towards zero, as pandas' Timedelta drops its nanoseconds.
|
|
175
|
+
micros = abs(nanos) // 1000
|
|
176
|
+
return dt.timedelta(microseconds=-micros if nanos < 0 else micros)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def type_of(values: list) -> str | None:
|
|
180
|
+
"""The llusen type to name for `values`, when llusen could not work
|
|
181
|
+
it out from the JSON alone: dates, datetimes, times and durations,
|
|
182
|
+
which JSON sends as text. None leaves it to llusen (numbers,
|
|
183
|
+
booleans, text)."""
|
|
184
|
+
for value in values:
|
|
185
|
+
if value is None or type(value).__name__ in ("NaTType", "NAType"):
|
|
186
|
+
continue
|
|
187
|
+
if isinstance(value, dt.datetime) or type(value).__name__ in (
|
|
188
|
+
"Timestamp",
|
|
189
|
+
"datetime64",
|
|
190
|
+
):
|
|
191
|
+
return "datetime"
|
|
192
|
+
if isinstance(value, dt.date):
|
|
193
|
+
return "date"
|
|
194
|
+
if isinstance(value, dt.time):
|
|
195
|
+
return "time"
|
|
196
|
+
if isinstance(value, dt.timedelta) or type(value).__name__ in (
|
|
197
|
+
"Timedelta",
|
|
198
|
+
"timedelta64",
|
|
199
|
+
):
|
|
200
|
+
return "duration"
|
|
201
|
+
return None
|
|
202
|
+
return None
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: llusen
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read and change the project open in llusen from Python
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.14
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Provides-Extra: pandas
|
|
10
|
+
Requires-Dist: pandas>=2.2; extra == "pandas"
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# llusen for Python
|
|
14
|
+
|
|
15
|
+
Read and change the project open in llusen from Python: tables, formulas,
|
|
16
|
+
filters, summary values, group tables, charts and notes; and export them
|
|
17
|
+
as CSV, Excel or OpenDocument files.
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import llusen
|
|
21
|
+
|
|
22
|
+
with llusen.connect() as project:
|
|
23
|
+
orders = project.table("orders")
|
|
24
|
+
orders.add_column("total", "price * qty")
|
|
25
|
+
orders.set_format("total", 'currency("£", 2)')
|
|
26
|
+
print(orders.summary())
|
|
27
|
+
frame = orders.to_pandas() # with pandas installed
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Setting up
|
|
31
|
+
|
|
32
|
+
- **Python 3.14** or later. The package uses only the standard library;
|
|
33
|
+
pandas is optional, for `to_pandas` and for giving DataFrames.
|
|
34
|
+
- **Install** from PyPI: `pip install llusen`, or `pip install
|
|
35
|
+
"llusen[pandas]"` with pandas. From this folder: `pip install .`.
|
|
36
|
+
- **In llusen**, choose **Tools > API Enabled**. llusen then listens on
|
|
37
|
+
this machine only, and writes its port and a token to `api.json` in its
|
|
38
|
+
state folder:
|
|
39
|
+
- Linux: `~/.local/state/llusen` (or `$XDG_STATE_HOME/llusen`);
|
|
40
|
+
- macOS: `~/Library/Application Support/llusen`;
|
|
41
|
+
- Windows: `%LOCALAPPDATA%\llusen`.
|
|
42
|
+
|
|
43
|
+
`llusen.connect()` reads that file and gives the token. API Enabled is
|
|
44
|
+
remembered, and the token is new each time it is turned on.
|
|
45
|
+
|
|
46
|
+
- **Which llusen:** each project opens in a llusen of its own, with its
|
|
47
|
+
own API, turned on in its own window. `connect()` reaches the llusen
|
|
48
|
+
used last (the one whose window last had the keyboard), as `api.json`
|
|
49
|
+
names it. To reach the llusen that has a given project open, name the
|
|
50
|
+
project's file:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
with llusen.connect(project="~/reports/sales.llu") as project:
|
|
54
|
+
...
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
If no llusen has it open, or the one that has has its API off,
|
|
58
|
+
`NotRunning` says which. `llusen.find_project(path)` gives that
|
|
59
|
+
llusen's port and token without connecting. (Each llusen lists what
|
|
60
|
+
it has open in `instances` in the state folder.)
|
|
61
|
+
|
|
62
|
+
## How it works
|
|
63
|
+
|
|
64
|
+
- Changes go into the project as if made in llusen. Each is one step
|
|
65
|
+
that Ctrl+Z takes back in that table, and the status bar says what the
|
|
66
|
+
script did. A table a script makes opens in a tab.
|
|
67
|
+
- Rows are counted in the order they were loaded or added, from 0,
|
|
68
|
+
whatever the user's sort and filters show. Reading gives every row.
|
|
69
|
+
- Reading waits until formulas are worked out, so values are never half
|
|
70
|
+
done. llusen gives up on a call after five minutes of waiting.
|
|
71
|
+
- A call llusen refuses raises `LlusenError`, with llusen's own message.
|
|
72
|
+
For a formula, `error.at` is the (start, end) of the problem in it.
|
|
73
|
+
- If llusen isn't running, or API Enabled is off, `connect` raises
|
|
74
|
+
`NotRunning` (a `ConnectionError`).
|
|
75
|
+
|
|
76
|
+
## Exporting
|
|
77
|
+
|
|
78
|
+
llusen writes the file, so the path is on the machine llusen runs on. A
|
|
79
|
+
relative path, or one from `~`, is made whole from the script's folder
|
|
80
|
+
first. A file already there is replaced, through a temporary file, so a
|
|
81
|
+
failed write leaves it as it was. Rows go in the order llusen shows them
|
|
82
|
+
(sorted, every row, whatever the filters). llusen writes on a thread of
|
|
83
|
+
its own and answers once the file is complete; the status bar says so.
|
|
84
|
+
The path's extension decides the kind (`.csv`, `.xlsx`, `.ods`), unless
|
|
85
|
+
`format` says; a path with no extension gets the format's.
|
|
86
|
+
|
|
87
|
+
## Values
|
|
88
|
+
|
|
89
|
+
| llusen | Python | pandas |
|
|
90
|
+
|----------|-------------------------------|---------------------------|
|
|
91
|
+
| int | `int` | `int64` (`Int64` if any are missing) |
|
|
92
|
+
| float | `float` | `float64` |
|
|
93
|
+
| bool | `bool` | `bool` (`boolean` if any are missing) |
|
|
94
|
+
| text | `str` | `object` |
|
|
95
|
+
| date | `datetime.date` | `datetime64` |
|
|
96
|
+
| datetime | `datetime.datetime` (UTC, aware, if the column is UTC) | `datetime64` |
|
|
97
|
+
| time | `datetime.time` | `object` (`datetime.time`) |
|
|
98
|
+
| duration | `datetime.timedelta` | `timedelta64` |
|
|
99
|
+
| null | `None` | missing |
|
|
100
|
+
| a fault such as `#DIV0` | `llusen.Fault("#DIV0")` | missing |
|
|
101
|
+
|
|
102
|
+
When giving values, NaN, `NaT` and `pd.NA` are null; numpy's numbers are
|
|
103
|
+
numbers; a naive datetime is sent as it is, and an aware one in UTC;
|
|
104
|
+
pandas' `Timedelta` and numpy's `timedelta64` are durations (to the
|
|
105
|
+
microsecond), and a time of day must have no time zone. A
|
|
106
|
+
new column's type is worked out from its values (whole numbers make
|
|
107
|
+
`int`, any decimal makes `float`), unless named in `types`.
|
|
108
|
+
|
|
109
|
+
## Reference
|
|
110
|
+
|
|
111
|
+
`llusen.connect(port=None, token=None, info_file=None, timeout=360,
|
|
112
|
+
project=None)` gives a `Project`: with `project`, the llusen that has
|
|
113
|
+
that project file open. Use it in a `with` block, or call `close()`. One
|
|
114
|
+
connection is for one thread at a time.
|
|
115
|
+
|
|
116
|
+
**Project**
|
|
117
|
+
- `describe()`: every table as llusen describes it (columns, types,
|
|
118
|
+
formulas, formats, filters, sort, summary values, groupings, charts,
|
|
119
|
+
notes).
|
|
120
|
+
- `table_names()`, `table(name)`, `chart(name)`.
|
|
121
|
+
- `new_table(name, data, types=None)`: `data` is a DataFrame, or a dict
|
|
122
|
+
of column names to lists of values.
|
|
123
|
+
- `new_group_table(name, grouping)`, e.g.
|
|
124
|
+
`'table("orders").group(r => ({ Item: r.item }))'`.
|
|
125
|
+
- `new_chart(name, table, x, ys, kind=None)`: `ys` is a column or a list
|
|
126
|
+
of them; `kind` is `"bar"`, `"line"` or `"doughnut"` (a doughnut
|
|
127
|
+
takes one column of values).
|
|
128
|
+
- `export(path, format=None, tables=None)`: every table (or those
|
|
129
|
+
named) to an Excel (`.xlsx`) or OpenDocument (`.ods`) workbook, a sheet
|
|
130
|
+
each, with summary values at the top and column formats; charts are
|
|
131
|
+
left out. Gives `{"path", "rows", "sheets"}` once written.
|
|
132
|
+
- `call(method, **params)`: any API method, as llusen documents it.
|
|
133
|
+
|
|
134
|
+
**Table**
|
|
135
|
+
- `read(columns=None, start=0, count=None)`: `{name: [values]}`, read in
|
|
136
|
+
pieces of 100,000 rows. `to_pandas(columns=None)`.
|
|
137
|
+
- `columns`, `len(table)`, `summary()` (values by name), `describe()`.
|
|
138
|
+
- `add_column(name, formula=None, values=None, type=None)`,
|
|
139
|
+
`set_formula`, `remove_formula`, `rename_column`,
|
|
140
|
+
`delete_columns(*names)`, `set_format(column, format=None)`.
|
|
141
|
+
- `append_rows(data)` gives the first new row; `set_cells(column,
|
|
142
|
+
values, start=0)`, all or none.
|
|
143
|
+
- `change_case(column, case)` puts every cell of a text column in
|
|
144
|
+
`"upper"`, `"lower"`, `"capitalize"` (`Hello world`) or `"title"`
|
|
145
|
+
(`Hello World`) case, and gives how many changed. The formula
|
|
146
|
+
functions of the same names do it in a new column instead.
|
|
147
|
+
- Live rows: `with table.locked(): table.insert_rows(data)`, or
|
|
148
|
+
`lock()`, `insert_rows(data)` and `unlock()`. While a script has a
|
|
149
|
+
table locked, rows go in at its end, many times a second if need be,
|
|
150
|
+
with no undo steps; llusen lifts any sort and filters, and no one else
|
|
151
|
+
can change the table (the user watches it fill, scrolled to the end).
|
|
152
|
+
`insert_rows` is answered at once, with the row its first will be at,
|
|
153
|
+
and its rows go in as soon as the table, and the tables reading it,
|
|
154
|
+
have worked out the last ones. `unlock` waits until all are in. The
|
|
155
|
+
lock goes if the script's connection closes. `is_locked` says whether
|
|
156
|
+
a script has it.
|
|
157
|
+
- `lock(clear=False, columns=None)`: `columns` are the rows' columns and
|
|
158
|
+
types, as `{"price": "float", "side": "text"}`. With `clear=True`, the
|
|
159
|
+
table is emptied for a new run and its plain columns made exactly
|
|
160
|
+
those, in that order (formula columns stay, after them), with no undo
|
|
161
|
+
step; it gives what changed. Without, they are checked against the
|
|
162
|
+
table's, and a difference refuses the lock. `locked()` takes the same.
|
|
163
|
+
- `set_filter(column, formula=None)` gives the rows shown; `value` is the
|
|
164
|
+
column's cell, as in `"value > 5"`. `clear_filters()`.
|
|
165
|
+
- `add_summary(formula, name=None, format=None)` gives its name;
|
|
166
|
+
`set_summary_formula`, `set_summary_format`, `delete_summary`.
|
|
167
|
+
- `set_grouping(grouping)` for a group table.
|
|
168
|
+
- `export(path, format=None)`: as CSV, or as a workbook of this table if
|
|
169
|
+
the path ends in `.xlsx` or `.ods` (or `format` says so).
|
|
170
|
+
- `rename(name)`, `delete()`.
|
|
171
|
+
|
|
172
|
+
**Chart**
|
|
173
|
+
- `notes()`, `add_note(text, anchors=())` gives its id. Each anchor is a
|
|
174
|
+
`(series, x)` pair, where `x` is a value of the column along the
|
|
175
|
+
bottom: a date, a time, a duration, a number, or text as llusen shows
|
|
176
|
+
it. A series of
|
|
177
|
+
`""` puts the note on every series at that place. A note's anchors
|
|
178
|
+
all share one series, or are all `""`.
|
|
179
|
+
- `set_settings(kind=None, **settings)`: the kind (`"bar"`, `"line"`,
|
|
180
|
+
`"doughnut"`) and the settings menu, as `smooth`, `line_fill`,
|
|
181
|
+
`line_from_zero`, `horizontal`, `bar_from_zero` and `pie` (each
|
|
182
|
+
`True` or `False`); what is left out stays. Gives whether it changed.
|
|
183
|
+
- `hidden_series()`, and `set_hidden_series(series)` to hide those
|
|
184
|
+
series from the key, as a click on it does, showing the rest. At
|
|
185
|
+
least one series stays shown.
|
|
186
|
+
- `rename(name)`, `delete()`.
|
|
187
|
+
|
|
188
|
+
## The protocol
|
|
189
|
+
|
|
190
|
+
Each message is one line of JSON-RPC 2.0 on a TCP connection to
|
|
191
|
+
`127.0.0.1`, with `authenticate {token}` first. See the module notes in
|
|
192
|
+
`llusen-core/src/api.rs` for every method and its params, if you want to
|
|
193
|
+
call llusen from another language.
|
|
194
|
+
|
|
195
|
+
## Tests
|
|
196
|
+
|
|
197
|
+
`python -m unittest discover -s tests` runs the tests against a stand-in
|
|
198
|
+
for llusen. `examples/orders.py` is a tour against the real thing.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
llusen/__init__.py,sha256=rOimk8k3kYFvTZG95Y8s3FYOSc07yv__iO3Jw_8Lx0A,873
|
|
2
|
+
llusen/_client.py,sha256=qnh75iTkGSlXm6uxtGz6NcgeCwB8zIlBygs_Mqk8lYM,23500
|
|
3
|
+
llusen/_frames.py,sha256=02Kp_-PnQ00ZvgWDGNBsXdnhQd8OAs0_o1oyUDNuWps,3267
|
|
4
|
+
llusen/_values.py,sha256=ivJP_9r1X3qaF5UTuGPwsh3FzL5-O5TBpACo_f_dcF4,6917
|
|
5
|
+
llusen-0.1.0.dist-info/licenses/LICENSE,sha256=OtiZ7YOoorvhkdzidnXCXc5O7-dUx24gUb84Aw2m2hY,1074
|
|
6
|
+
llusen-0.1.0.dist-info/METADATA,sha256=bJ7L7IC1QJ6XX-wIR-3jpVhY56aqVU7AIhBoYaOcT_s,9553
|
|
7
|
+
llusen-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
8
|
+
llusen-0.1.0.dist-info/top_level.txt,sha256=EEeEsnkwAFPmRJYdgTdFurgfsd_A9jIS92R6a1Wsg5U,7
|
|
9
|
+
llusen-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anthony Blackshaw
|
|
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 @@
|
|
|
1
|
+
llusen
|