Metadata-Version: 2.4
Name: llusen
Version: 0.1.2
Summary: Read and change the project open in llusen from Python
License-Expression: MIT
Requires-Python: >=3.14
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: pandas
Requires-Dist: pandas>=2.2; extra == "pandas"
Dynamic: license-file

# llusen for Python

Read and change the project open in llusen from Python: tables, formulas,
filters, summary values, group tables, charts and notes; and export them
as CSV, Excel or OpenDocument files.

```python
import llusen

with llusen.connect() as project:
    orders = project.table("orders")
    orders.add_column("total", "price * qty")
    orders.set_format("total", 'currency("£", 2)')
    print(orders.summary())
    frame = orders.to_pandas()          # with pandas installed
```

## Setting up

- **Python 3.14** or later. The package uses only the standard library;
  pandas is optional, for `to_pandas` and for giving DataFrames.
- **Install** from PyPI: `pip install llusen`, or `pip install
  "llusen[pandas]"` with pandas. From this folder: `pip install .`.
- **In llusen**, choose **Tools > API Enabled**. llusen then listens on
  this machine only, and writes its port and a token to `api.json` in its
  state folder:
  - Linux: `~/.local/state/llusen` (or `$XDG_STATE_HOME/llusen`);
  - macOS: `~/Library/Application Support/llusen`;
  - Windows: `%LOCALAPPDATA%\llusen`.

  `llusen.connect()` reads that file and gives the token. API Enabled is
  remembered, and the token is new each time it is turned on.

- **Which llusen:** each project opens in a llusen of its own, with its
  own API, turned on in its own window. `connect()` reaches the llusen
  used last (the one whose window last had the keyboard), as `api.json`
  names it. To reach the llusen that has a given project open, name the
  project's file:

  ```python
  with llusen.connect(project="~/reports/sales.llu") as project:
      ...
  ```

  If no llusen has it open, or the one that has has its API off,
  `NotRunning` says which. `llusen.find_project(path)` gives that
  llusen's port and token without connecting. (Each llusen lists what
  it has open in `instances` in the state folder.)

## How it works

- Changes go into the project as if made in llusen. Each is one step
  that Ctrl+Z takes back in that table, and the status bar says what the
  script did. A table a script makes opens in a tab.
- Rows are counted in the order they were loaded or added, from 0,
  whatever the user's sort and filters show. Reading gives every row.
- Reading waits until formulas are worked out, so values are never half
  done. llusen gives up on a call after five minutes of waiting.
- A call llusen refuses raises `LlusenError`, with llusen's own message.
  For a formula, `error.at` is the (start, end) of the problem in it.
- If llusen isn't running, or API Enabled is off, `connect` raises
  `NotRunning` (a `ConnectionError`).

## Exporting

llusen writes the file, so the path is on the machine llusen runs on. A
relative path, or one from `~`, is made whole from the script's folder
first. A file already there is replaced, through a temporary file, so a
failed write leaves it as it was. Rows go in the order llusen shows them
(sorted, every row, whatever the filters). llusen writes on a thread of
its own and answers once the file is complete; the status bar says so.
The path's extension decides the kind (`.csv`, `.xlsx`, `.ods`), unless
`format` says; a path with no extension gets the format's.

## Values

| llusen   | Python                        | pandas                    |
|----------|-------------------------------|---------------------------|
| int      | `int`                         | `int64` (`Int64` if any are missing) |
| float    | `float`                       | `float64`                 |
| bool     | `bool`                        | `bool` (`boolean` if any are missing) |
| text     | `str`                         | `object`                  |
| date     | `datetime.date`               | `datetime64`              |
| datetime | `datetime.datetime` (UTC, aware, if the column is UTC) | `datetime64` |
| time     | `datetime.time`               | `object` (`datetime.time`) |
| duration | `datetime.timedelta`          | `timedelta64`             |
| null     | `None`                        | missing                   |
| a fault such as `#DIV0` | `llusen.Fault("#DIV0")` | missing          |

When giving values, NaN, `NaT` and `pd.NA` are null; numpy's numbers are
numbers; a naive datetime is sent as it is, and an aware one in UTC;
pandas' `Timedelta` and numpy's `timedelta64` are durations (to the
microsecond), and a time of day must have no time zone. A
new column's type is worked out from its values (whole numbers make
`int`, any decimal makes `float`), unless named in `types`.

## Reference

`llusen.connect(port=None, token=None, info_file=None, timeout=360,
project=None)` gives a `Project`: with `project`, the llusen that has
that project file open. Use it in a `with` block, or call `close()`. One
connection is for one thread at a time.

**Project**
- `describe()`: every table as llusen describes it (columns, types,
  formulas, formats, filters, sort, summary values, groupings, charts,
  notes).
- `table_names()`, `table(name)`, `chart(name)`.
- `new_table(name, data, types=None)`: `data` is a DataFrame, or a dict
  of column names to lists of values.
- `new_group_table(name, grouping)`, e.g.
  `'table("orders").group(r => ({ Item: r.item }))'`.
- `new_chart(name, table, x, ys, kind=None)`: `ys` is a column or a list
  of them; `kind` is `"bar"`, `"line"` or `"doughnut"` (a doughnut
  takes one column of values).
- `export(path, format=None, tables=None)`: every table (or those
  named) to an Excel (`.xlsx`) or OpenDocument (`.ods`) workbook, a sheet
  each, with summary values at the top and column formats; charts are
  left out. Gives `{"path", "rows", "sheets"}` once written.
- `call(method, **params)`: any API method, as llusen documents it.

**Table**
- `read(columns=None, start=0, count=None)`: `{name: [values]}`, read in
  pieces of 100,000 rows. `to_pandas(columns=None)`.
- `columns`, `len(table)`, `summary()` (values by name), `describe()`.
- `add_column(name, formula=None, values=None, type=None)`,
  `set_formula`, `remove_formula`, `rename_column`,
  `delete_columns(*names)`, `set_format(column, format=None)`.
- `append_rows(data)` gives the first new row; `set_cells(column,
  values, start=0)`, all or none.
- `change_case(column, case)` puts every cell of a text column in
  `"upper"`, `"lower"`, `"capitalize"` (`Hello world`) or `"title"`
  (`Hello World`) case, and gives how many changed. The formula
  functions of the same names do it in a new column instead.
- Live rows: `with table.locked(): table.insert_rows(data)`, or
  `lock()`, `insert_rows(data)` and `unlock()`. While a script has a
  table locked, rows go in at its end, many times a second if need be,
  with no undo steps; llusen lifts any sort and filters, and no one else
  can change the table (the user watches it fill, scrolled to the end).
  `insert_rows` is answered at once, with the row its first will be at,
  and its rows go in as soon as the table, and the tables reading it,
  have worked out the last ones. `unlock` waits until all are in. The
  lock goes if the script's connection closes. `is_locked` says whether
  a script has it.
- `lock(clear=False, columns=None)`: `columns` are the rows' columns and
  types, as `{"price": "float", "side": "text"}`. With `clear=True`, the
  table is emptied for a new run and its plain columns made exactly
  those, in that order (formula columns stay, after them), with no undo
  step; it gives what changed. Without, they are checked against the
  table's, and a difference refuses the lock. `locked()` takes the same.
- `set_filter(column, formula=None)` gives the rows shown; `value` is the
  column's cell, as in `"value > 5"`. `clear_filters()`.
- `set_sort(column, direction="ascending")` sorts the rows shown by a
  column, `"ascending"` or `"descending"`; `clear_sort()` puts them back
  as they were. Rows read are stored rows, whatever the sort.
- `add_summary(formula, name=None, format=None)` gives its name;
  `set_summary_formula`, `set_summary_format`, `delete_summary`.
- `set_grouping(grouping)` for a group table.
- `export(path, format=None)`: as CSV, or as a workbook of this table if
  the path ends in `.xlsx` or `.ods` (or `format` says so).
- `rename(name)`, `delete()`.

**Chart**
- `notes()`, `add_note(text, anchors=())` gives its id. Each anchor is a
  `(series, x)` pair, where `x` is a value of the column along the
  bottom: a date, a time, a duration, a number, or text as llusen shows
  it. A series of
  `""` puts the note on every series at that place. A note's anchors
  all share one series, or are all `""`.
- `set_settings(kind=None, **settings)`: the kind (`"bar"`, `"line"`,
  `"doughnut"`) and the settings menu, as `smooth`, `line_fill`,
  `line_from_zero`, `horizontal`, `bar_from_zero` and `pie` (each
  `True` or `False`); what is left out stays. Gives whether it changed.
- `hidden_series()`, and `set_hidden_series(series)` to hide those
  series from the key, as a click on it does, showing the rest. At
  least one series stays shown.
- `rename(name)`, `delete()`.

## The protocol

Each message is one line of JSON-RPC 2.0 on a TCP connection to
`127.0.0.1`, with `authenticate {token}` first. See the module notes in
`llusen-core/src/api.rs` for every method and its params, if you want to
call llusen from another language.

## Tests

`python -m unittest discover -s tests` runs the tests against a stand-in
for llusen. `examples/orders.py` is a tour against the real thing.
