# i7lib 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. ## Availability Inside a job container the library is already importable: it is installed in the job's image, built from the `python-base` repository, whose `local-requirements.txt` names the `i7lib` version. Nothing is mounted from the gateway, so a new `i7lib` version reaches a script only through a new image tag. Outside a job, install the wheel published to this org's 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 in its `signals.yml`; the names used as keys are the ones written there. 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.sidecar_calls Email and history calls through the job's sidecar (`docs/script-calls.md` in the i7sidecar repository has the routes). They need a sidecar image that carries the gateway link. Unlike the rest of this library, these three **raise `SidecarError`** on failure instead of printing and returning an empty result, because a lost email or an empty report must not look like success. `SidecarError` has `status` (HTTP status, `0` when the sidecar could not be reached), `code` and `message`. All three retry while the sidecar answers 503 or cannot be reached, pausing 1 s, 2 s, 4 s and up to 10 s, until `retry_seconds` has passed. Every other failure raises at once. **`send_email(to, subject, body_text="", body_html="", cc=None, bcc=None, attachments=None, sender=None, reply_to="", display_name="", ttl_seconds=86400, email_id=None, retry_seconds=60)`** — returns `{"emailId", "state"}` with state `queued`, meaning the gateway holds the email durably, not that it was delivered. `to` is an address or a list. `attachments` is a list of `(filename, bytes)`, `(filename, bytes, content_type)` or `{"filename", "content", "content_type"}`. `sender` is omitted for the platform's own sender, or `{"mode": "smtp", "host", "port", "security", "username", "fromAddress", "passwordSealed"}` with the sealed password as bytes. One `email_id` is generated per call and reused across its retries, so a retry after a lost answer never sends the email twice; pass your own `email_id` to make a repeated call of the whole script safe too. Limits: 50 recipients, 10 MiB of body and attachments together, `ttl_seconds` up to 604800 (7 days). Refusals: 400 `invalid`, 413 `too_large`, 429 `queue_full`. **`email_status(email_id, retry_seconds=30)`** — returns `{"state", "detail", "attempts", "updatedMs"}` with state `queued`, `sent`, `failed` or `expired`. An unknown id raises with status 404. **`read_history(signals, start_ms, end_ms, resolution="1min", retry_seconds=60)`** — `signals` is one or a list of signal names or ids, `resolution` is `1s`, `1min` or `1hr`. Returns a dict in which every series is found by signal id and by signal name; each series has `signalId`, `name`, `startMs`, `stepMs`, `values` (`None` for a gap) and `times` (the millisecond of each value). Only signals the job's `signals.yml` lists with `read: 1` can be read, otherwise 403. More than 200 signals raises 400, and an answer over 8 MiB raises 413. ```python from i7lib import read_history, send_email from i7lib.timeutils import now history = read_history(["oven-temperature"], now() - 3600_000, now(), resolution="1min") series = history["oven-temperature"] send_email("plant@example.com", "Oven report", body_text=f"{len(series['values'])} samples") ``` ## 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': }`, 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. ## Versioning and the pyproject template There is no `pyproject.toml` in this repository — it is generated from `pyproject.toml.tmpl`, whose version line reads `version = "{{vless .TagName}}"`. gitea-sidecar renders every `*.tmpl` in the source to its name minus the suffix before building the wheel, so a tag of `v0.1.5` publishes version `0.1.5`; `vless` strips the leading `v` that git wants and PEP 440 forbids. The tag is the only place a version is written, and the published wheel cannot disagree with the tag that produced it. Do not put a hardcoded version back into the template: the build would keep producing that one version, and the next tag would fail with a 409 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. ## Error handling Every method in this library, except the three in `i7lib.sidecar_calls`, 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.