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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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