Metadata-Version: 2.5
Name: hectiqlab
Version: 0.3.0
Summary: Record runs, stages, metrics, datasets and models to Hectiq Lab, from a script or the command line.
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic-settings>=2.3
Requires-Dist: pydantic>=2.7
Requires-Dist: rich>=13.7
Requires-Dist: tomli>=2.0; python_version < '3.11'
Provides-Extra: cli
Requires-Dist: typer>=0.12; extra == 'cli'
Provides-Extra: parquet
Requires-Dist: pyarrow>=15; extra == 'parquet'
Provides-Extra: tui
Requires-Dist: textual-plotext>=1.0; extra == 'tui'
Requires-Dist: textual>=8.2; extra == 'tui'
Requires-Dist: typer>=0.12; extra == 'tui'
Description-Content-Type: text/markdown

# hectiqlab

Record runs, stages, metrics, datasets and models to [Hectiq Lab](https://lab.hectiq.ai), from a training script or the command line. Everything goes to the API the web app reads, over HTTP, from one background thread, so a training loop never waits on the network.

This package lives beside `pyhectiqlab` (in `sdk/`) and does not replace it: both can be installed together, and they share `~/.hectiq-lab/credentials.toml`.

```bash
pip install hectiqlab                 # the SDK
pip install "hectiqlab[cli]"          # and the `hlab` command line
pip install "hectiqlab[tui]"          # and the dashboard `hlab ui` opens
pip install "hectiqlab[tui,parquet]"  # and parquet previews in it, which take pyarrow
```

## A script

```python
import hectiqlab as hl

with hl.run(
    "two-tower, 3 epochs",
    category="training",
    tags=["ENG-42"],
    config={"lr": 1e-3, "epochs": 3},
):
    data = hl.datasets.get("interactions").unwrap()
    hl.datasets.attach(data.id)
    with hl.stage("train"):
        for epoch in hl.each(range(3), "epoch"):
            loss = train_one_epoch()
            hl.add_metric("loss", step=epoch, value=loss)
    hl.models.create("checkpoints/", name="two-tower").result()
```

A run captures its commit, branch, diff, installed packages, machine and command line as it opens, and ends `completed`, `failed` (with its traceback) or `stopped`, whether it was opened with `with` or left open until the script crashed or exited. One exit it cannot see: a run opened without `with` in a script that calls `sys.exit(1)` ends `completed`, because Python never hands a `SystemExit` to the exception hook; open the run with `with` to record the exit status. Stages nest: a `with`, a decorated function or a named `each` opens a stage inside whatever is open. Reads and changes return a `Result`; uploads and downloads return a `Future`, and an upload made while a run is open holds the run open until it lands, however long that takes (the bar shows it moving; Ctrl-C ends the run `stopped` without it).

An API that is down, slow or refusing never raises in the middle of a training loop: a failed read or change is an `Err`, and what a run recorded that did not land is said once, as a warning, when the run closes (`run.result()` raises it instead). A mistake in the call itself does raise, at once: a metric or a stage with no run open, a category the web app does not know.

Uploads and downloads draw a progress bar on stderr when it is a terminal. `progress=False` on the call, or `HECTIQLAB_PROGRESS=false`, draws none; `progress=True` draws one anyway.

The project comes from `project=`, `hl.set_project(...)`, `HECTIQLAB_PROJECT`, or the `.hectiqlab/config.toml` that `hlab init` writes. `HECTIQLAB_DISABLE=1`, or a `RANK`/`LOCAL_RANK` other than 0, records nothing.

A run names the ticket it serves with a tag, `ENG-42`; nothing here speaks to Linear. The ticket names its runs by citing them: every run read, in Python or with `--json`, carries `url`, its page in the web app. `hlab run list --tag ENG-42 --json` hands an agent, or a person, every run a ticket's work made, each with the address to cite.

## The command line

```bash
hlab login                      # make a key with your email and password
hlab init hectiq-ai/recsys      # this checkout records to that project
hlab run list --tag ENG-42 --json   # each run with its url, to cite on the ticket
hlab run get 42 --json          # runs are named by their rank, or their id
hlab run stages 42
hlab run tag 42 best            # dataset and model tag the same way
hlab metric get 42 loss
hlab dataset create data/ --name interactions --no-upload
hlab model download two-tower --version 1.3
hlab artifact get art_4f2c
hlab launch --tag TRA-20 -- python train.py   # see Launch, below
```

Every command that reads or changes something takes `--json`, and prints one JSON document on stdout; prompts and progress bars go to stderr.

## Launch

`hlab launch [options] -- <command>` runs a command and says, at launch, how the lab records it. The rule: a launch changes how the work is recorded, never what it recorded. It sets a run's title, description, category and tags, and whether a dataset or a model is registered, under which name and version, and whether its bytes go up; it never renames a metric, moves an artifact or changes a stage, and touches the config only as below.

```bash
hlab launch --title "backfill, March" --tag TRA-20 --category evaluation -- python train.py
hlab launch --wrap --title "nightly export" --dataset out/rows.parquet=rows -- ./export.sh
hlab launch --prompt -- python train.py
```

For a script that calls the SDK, the options reach the first run it opens, and only that one; the runs after it open as the code says. The launch's title, description and category replace the code's. Its tags join the code's. `hl.run` still takes a title, which the launch replaces.

`--wrap` records any command as one run, whether it calls the SDK or not. It needs a title, from `--title` or the prompt. `hlab launch` opens the run before the command starts, with the command as its entrypoint, and ends it the way the command ended: `completed` on exit 0, `failed` otherwise, `stopped` on Ctrl-C, which reaches the command while `hlab launch` waits for it to end. `--config`, a JSON object or a JSON file, is the run's config, and the only way a launch touches one: a script's own run puts its config over it, a key the code sets winning, because the code knows what it ran with. Each `--dataset PATH=NAME` and `--model PATH=NAME` is registered on the run, uploaded, once the command succeeded, and never after a failure. A script that opens its own `hl.run` under `--wrap` joins the wrapper's run instead of opening a second one: its config, tags, description, packages, entrypoint, stages, metrics and traceback land on it, and the wrapper ends it.

The limits of `--wrap`: a command that makes no SDK call records no metric and no stage; one that fails without the SDK leaves `exited with code N` as its traceback, not a Python one; and for a command that opens no run, the packages the run lists are those of the environment `hlab` runs in, which is right with `uv run hlab launch` and wrong with `uvx hlab launch`, and the run names no Python. A script that opens its own run sends its own packages, and its own script, arguments and Python.

`hlab launch` exits with the command's own exit code, so a pipeline sees what it would see without it. With the SDK switched off, it only runs the command.

`--prompt`, or `HECTIQLAB_PROMPT=1`, asks on the terminal. The first run asks for its title, tags and category when a launch says anything about it; under `--wrap`, `hlab launch` asks before the command starts. Each dataset or model asks whether to register it at all, then its name, its version (empty for the next minor) and whether to upload its files. A run asks, as it closes, whether to keep it: no hides it, and nothing is deleted. Every question defaults to what the code or the options say. Without a terminal, in CI or behind a pipe, nothing is asked, the defaults apply, and one log line says so.

The options travel as environment variables, which the command inherits, so a CI job can set them and run `python train.py` with no wrapper:

| Variable | What it says |
| --- | --- |
| `HECTIQLAB_RUN_TITLE` | The first run's title |
| `HECTIQLAB_RUN_DESCRIPTION` | Its description |
| `HECTIQLAB_RUN_CATEGORY` | Its category |
| `HECTIQLAB_RUN_TAGS` | Tags added to its own, comma-separated: `TRA-20,backfill` |
| `HECTIQLAB_RUN_CONFIG` | Config under its own when it joins the run `--wrap` opened, ignored otherwise: a JSON object, or the path to a JSON file |
| `HECTIQLAB_PROMPT` | `1` to ask on the terminal |

## The dashboard

`hlab ui` opens a terminal dashboard onto a project, `hectiqlab.tui`; `?` lists the keys. It opens on the project the SDK would record to (`-p`, `HECTIQLAB_PROJECT`, or `.hectiqlab/config.toml`), and on the switcher when nothing names one; `P` switches from anywhere. `1` to `5` are the dashboard, runs, datasets, models and tags.

Everything it shows comes from the API the web app reads, through the SDK's own read functions: it refreshes every second, and reads the series of the open runs and of those that ended in the last fourteen days, a hundred runs to a request. It reads only the series on screen: an older run's come when it is opened. An open run that has given no sign of life for an hour, no change to its row, no sample, no stage, is shown as silent and read once a minute. A file previews over its signed address a range at a time, so a 20 GB parquet shows its schema, its row count and its first rows without being downloaded; without the `parquet` extra, a parquet says what to install instead.

A screen (`tui/screens/`, built from `tui/widgets/`) never calls the SDK. It asks `tui/reads/`, and draws the SDK's own records. Importing `hectiqlab` never imports the dashboard.

## Development

```bash
cd sdk_v2
uv sync
uv run ruff check . && uv run ruff format --check . && uv run ty check && uv run lint-imports
uv run pytest -m "not integration"
```

The integration tests talk to the API running in `api/compose.yaml`, logged in as the user they seed it with, whose email and password come from your environment. They are skipped when the stack is not up or the login is not set, and fail instead with `HECTIQLAB_INTEGRATION=required`, as in CI:

```bash
docker compose -f ../api/compose.yaml up -d --build
export HECTIQLAB_TEST_EMAIL=you@example.com HECTIQLAB_TEST_PASSWORD=...  # 8+ chars, upper, lower, digit
uv run pytest -m integration
```

The stack, its login, the fresh key and project each test gets, and the clean process every test starts from are in `testing/hectiqlab_stack.py`, a pytest plugin every test loads. It is not part of the wheel. The dashboard's tests, in `tests/tui/`, read a seeded project instead of the API, except those in `tests/tui/integration/`.

On a pull request that touches `api/` or `sdk_v2/`, `.github/workflows/hectiqlab_checks.yml` runs these checks and the unit tests on Python 3.10 and 3.13, and the integration tests against one stack.

## Releasing

`hectiqlab` is one package, the SDK, the command line and the dashboard at one version. A release is one button: **Actions → Release hectiqlab → Run workflow** on `main`, picking `patch`, `minor` or `major` (or `gh workflow run release_hectiqlab.yml -f bump=minor`).

The workflow (`.github/workflows/release_hectiqlab.yml`) runs the checks, including the integration tests; moves the version and re-locks; builds the package and checks its metadata; commits `Release hectiqlab X.Y.Z` to `main` and tags it `hectiqlab-vX.Y.Z`; creates the GitHub release with the subjects of the commits under `sdk_v2/` since the last release; then publishes it to PyPI. Nobody edits a version by hand. If only the publishing fails, re-running that job publishes the distributions already built.

The version steps live in `.github/scripts/release_hectiqlab.py`, which runs the same on a clone (`bump`, `notes`, `check`).

Once, before the first release, and close together, because a pending publisher does not reserve the name ([PyPI docs](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/)):

1. On pypi.org, under your account's **Publishing** page, add a GitHub Actions pending publisher for the project `hectiqlab`: repository `HectiqAI/hectiq-lab-revision`, workflow `release_hectiqlab.yml`, environment `pypi`.
2. In the repository's settings, create the `pypi` environment. Required reviewers on it make every release wait for an approval before it publishes.
3. Run the first release. It ships the version the pyproject already declares, `0.1.0`, whichever bump is picked; every release after it bumps.
