5 Commits
Author SHA1 Message Date
Iñigo Etxaniz 65520d0c74 templating 2026-08-07 16:10:10 +00:00
Iñigo Etxaniz 64b219fd32 templated project version 2026-08-07 16:09:51 +00:00
Iñigo Etxaniz dc3a0f8b0b Correct the signals framing in the README
signals.yml is managed by the sidecar, not authored by the customer, and
the write flag is used by the supporting services rather than being a
script author's lever. The previous text presented both as a customer
contract, which is wrong.

Now claims only runtime behaviour: which signals a job sees is configured
for it, an unexposed signal is absent rather than an error, and batch
rejection is described as the sidecar not recognising an id. Also points a
customer at their own job output rather than platform-side logs they may
not have access to.
2026-08-04 09:45:44 +02:00
Iñigo Etxaniz 16b9a265d1 Document the i7lib API
Customer-facing API reference for the three modules, written from source
plus the live test-script job. Scoped to the API: the signals.yml and
schedule.yaml contract gets only what a caller needs to read the API
correctly, not a full treatment.

Three behaviours documented because they are invisible from the source a
customer reads: a write batch is all-or-nothing on an unknown id, run()
skips logic_func entirely on a failed or empty read, and every method
swallows its exceptions so a falsy return conflates failure with empty.
The dbutils calls also set no timeout, unlike the signal calls.

pyproject.toml is unchanged; README.md is already its readme field.
2026-08-04 09:36:27 +02:00
Iñigo Etxaniz bb73c9901c Add packaging metadata so python -m build works
#110 recorded pyproject.toml + __init__.py as verified on a real toolchain
but they were never committed; origin has nothing past d5203ce.

pyproject.toml is deliberately plain: the sidecar's pypi.ReadProjectMetadata
is a line scanner, not a TOML parser, and reads name and version as literal
single-line assignments under [project]. A dynamic version would break both
the publish check and the startup reconcile.

__init__.py re-exports the public API so src/i7lib is an importable package.
.gitignore keeps dist/ out of the tree — a committed wheel would travel in
gitea's source archive and trip the builder's one-wheel-in-dist check.
2026-08-04 09:27:03 +02:00
4 changed files with 194 additions and 1 deletions
+6
View File
@@ -0,0 +1,6 @@
dist/
build/
*.egg-info/
__pycache__/
*.pyc
pyproject.toml
+166 -1
View File
@@ -1,3 +1,168 @@
# i7lib
i7 library for scripts
Python library for scripts running in an i7 job container. It gives a script three things:
plant signals (read and write) through the job's sidecar, the customer database through the
db-controller, and millisecond time helpers.
## Versioning and the pyproject template
There is no `pyproject.toml` in this repository. It is generated from
`pyproject.toml.tmpl`, and its version comes from the git tag that triggered the build:
```toml
version = "{{vless .TagName}}"
```
gitea-sidecar renders every `*.tmpl` file in the source to its name minus the suffix before
building the wheel, so a tag of `v0.1.1` produces version `0.1.1``vless` strips the
leading `v`, which git wants and PEP 440 does not. `.TagName` and `.CommitID` are also
available for anything else worth stamping.
The tag is therefore the only place a version is written. Do not add a hardcoded version
back into the template: the build would keep producing that one version, and publishing a
second tag would fail because the package file already exists.
**Building locally** needs the same render step, since the file the build tools look for is
not in the repository:
```bash
sed 's/{{vless .TagName}}/0.0.0.dev0/' pyproject.toml.tmpl > pyproject.toml
```
`pyproject.toml` is gitignored, so a local copy never reaches a build.
## Availability
Inside a job container the library is already importable — the scheduler mounts the SDK
folder and sets `PYTHONPATH`. Outside one, install the wheel published to your gitea PyPI
index. The only hard dependency is `requests`; `python-dotenv` is optional and loaded if
present, so a local `.env` can supply the two environment variables below.
## Endpoints and environment
| Variable | Default | Injected value in a job |
|---|---|---|
| `SIDECAR_URL` | `http://localhost:80` | `http://sidecar:80` |
| `DB_CONTROLLER_URL` | `http://127.0.0.1:13213` | `http://sidecar:8081` |
The defaults are for running a script on your own machine. In a job both are set for you, so
a script never hardcodes them.
## Quick start
```python
from i7lib import run
ID_OUTPUT = 49
def logic(plant):
temperature = plant.state.get("oven-temperature", 0.0)
batch = plant.new_write_batch()
batch.add(ID_OUTPUT, temperature * 2.0, "oven-output")
plant.send(batch)
if __name__ == "__main__":
run(logic, interval_ms=1000, offset_ms=200)
```
`from i7lib import ...` re-exports the public API; `from i7lib.client import run` and the
other submodule paths work equally well.
## Signals
Not every signal is available to every job. `plant.state` holds only the signals the job's
sidecar exposes to it, keyed by both id and name. Which those are is configured for the job;
a script does not declare them.
A signal that is not exposed is simply absent from `plant.state` — reading it does not raise.
Guard with `in` or `.get()` rather than assuming a key is there.
## i7lib.client
### `Plant`
Holds the last state read from the sidecar.
| Attribute | Contents |
|---|---|
| `state` | value by signal id **and** by signal name, for every signal that has a name |
| `instants` | timestamp of each value, keyed the same way |
| `signals` | the raw list returned by the sidecar |
A signal present but carrying no value reads as `nan`; its instant defaults to `0`.
**`read_all()`** — reads every subscribed signal once. Returns `state`, or `{}` on failure.
**`read_sync(interval_ms, offset_ms)`** — blocks until the sidecar's next sample aligned to
`interval_ms`, offset by `offset_ms`, then returns `state`, or `{}` on failure. This is the
call `run` uses; it is what keeps a script in step with the sample clock instead of drifting.
**`new_write_batch()`** — returns an empty `WriteBatch`.
**`send(batch)`** — posts the batch. Does nothing if the batch is empty.
### `WriteBatch`
**`add(signal_id, value, name="")`** — queues one write. `signal_id` is coerced to `int`,
`value` to `float`, `name` to `str`.
A batch is **all or nothing**. If the sidecar does not recognise one of the ids in it, the
entire batch is rejected and none of the writes land. An id it does recognise but which is
not writable is skipped without failing the batch. Neither outcome is visible to the caller —
see Error handling below.
### `run(logic_func, interval_ms=1000, offset_ms=200)`
The main loop. Creates a `Plant`, and on every cycle calls `read_sync` and then
`logic_func(plant)`. It never returns.
Two behaviours to know. When a read fails or comes back empty the cycle is skipped, the loop
sleeps one second and `logic_func` is not called at all. And an exception raised inside
`logic_func` is caught and printed, then the loop sleeps one second and continues — a script
does not die on a bad tick, so a persistent bug shows up as repeated log lines rather than a
crash.
## i7lib.dbutils
### `Database()`
Talks to the db-controller at `DB_CONTROLLER_URL`.
| Method | Returns |
|---|---|
| `get_row(table, row_id)` | the row, or `None` |
| `insert(table, data)` | `{'id': <new id>}`, or `None` |
| `query(table, filters=None, order_by=None, limit=100)` | list of rows, or `[]` |
| `update(table, row_id, data)` | `True` / `False` |
| `list_tables()` | list of table names, or `[]` |
| `list_columns(table)` | list of columns, or `[]` |
Table and column names come from the customer schema, not from this library.
These calls set no timeout, unlike the signal calls, so a db-controller that accepts a
connection and then stalls will block the script. Keep database work off the hot path of a
fast `interval_ms` loop.
## i7lib.timeutils
**`now()`** — current time in Unix milliseconds, the format used throughout the platform.
**`now_sec()`** — the same, floored to the second.
**`to_datetime(unix_milli)`** — converts back to a local `datetime`, for logging.
## Error handling
Every method in this library catches its own exceptions, prints a line, and returns an empty
or falsy result. Nothing raises, and no method reports which signal or row failed.
The practical consequences: `read_all` and `read_sync` returning `{}` mean "failed" and
"nothing subscribed" alike; `query` and `list_tables` returning `[]` mean "failed" and "no
rows" alike; `send` returning normally does **not** mean the writes were applied. A batch
rejected in full for one unknown id looks exactly like a successful send from inside the
script.
So when a value is not moving, check the job's own output first — that is where this library
prints its errors. If nothing appears there, the write reached the platform and was rejected
or dropped further in, which is visible only in the platform-side logs. Do not infer success
from the absence of an exception.
+17
View File
@@ -0,0 +1,17 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "i7lib"
version = "{{vless .TagName}}"
description = "i7 library for scripts"
readme = "README.md"
requires-python = ">=3.8"
dependencies = ["requests>=2.31"]
[project.optional-dependencies]
dotenv = ["python-dotenv>=1.0"]
[tool.setuptools.packages.find]
where = ["src"]
+5
View File
@@ -0,0 +1,5 @@
from . import timeutils
from .client import Plant, WriteBatch, run
from .dbutils import Database
__all__ = ["Database", "Plant", "WriteBatch", "run", "timeutils"]