Skip to content

How it is built

This page is for people working on the package. It covers where each thing lives and why it works the way it does.

The files

src/visin/
├── __init__.py          the public API: everything a user imports comes from here
├── run.py               Run and init: everything a training script writes
├── api.py               Api: reading runs back
├── models.py            Training, Epoch, TestResult, Benchmark, Project, Dataset: what Api returns
├── datasets.py          Datasets: list and download
├── offline.py           sync() and pending(): sending reports kept on disk
├── system.py            system_info() and system_metrics()
├── errors.py            VisinError and its subclasses
├── cli.py, __main__.py  the `visin` command
├── integrations/
│   ├── keras.py         Keras callback
│   ├── lightning.py     PyTorch Lightning callback
│   └── _metrics.py      sorting flat metric names into train/val/test
├── _internal/           machinery; not public, may change in any release
│   ├── config.py        settings from the environment and arguments
│   ├── inputs.py        epoch numbers, results and configs in the shapes callers pass them
│   ├── process.py       the process's rank, and learning how it ended (crash, SIGTERM)
│   ├── transport.py     HTTP: retries, response unwrapping, signed uploads
│   ├── serialize.py     NumPy/tensors/NaN into JSON Visin accepts
│   ├── delivery.py      one run's client, spool and sender: live sends, spooling, catch-up
│   ├── payloads.py      the request bodies a run builds from what a caller passed
│   ├── reports.py       what each kind of report is, and how it is delivered
│   ├── sender.py        the background thread reports are sent from
│   └── spool.py         reports on disk: the file format and syncing it
└── _version.py          the version, and the only place it is written

The rule of thumb: a module at the top level is something a user touches, and _internal is how it works.

One report, from call to server

run.log_epoch(...)                       run.py: validate, convert to JSON, build an op
   │   {"op": "epoch", "body": {...}}
   ▼
Sender queue ──► background thread       sender.py: one thread, so order is kept
   │
   ▼
deliver(client, op)                      reports.py: POST /epochs/upload; a 409 counts as delivered
   │
   ├─ delivered ──────────────► done
   ├─ refused (4xx, 500) ─────► logged and counted; retrying would not help
   └─ unreachable (5xx/down) ─► spool.py: appended to ~/.visin/runs/<uuid>.jsonl,
                                  sent by the run once Visin answers, or by `visin sync`

Every write is first an op: a small JSON dict naming its kind and carrying its request body. Because an op is data, the same op can be sent at once, written to disk, and sent from disk a week later by visin sync, and all three go through the one deliver function. There is a single code path for sending.

Decisions worth knowing

Nothing raises into the training loop. A metrics backend is not worth a training job. Failures are logged and counted, and finish summarises them. strict=True exists for tests. The one exception is starting a run: a refusal there (bad token, wrong project) raises, because every report after it would fail the same way and the person launching the job is there to read it.

Creating and attaching are separate. init always registers a run, resuming it when its UUID is known; Run.attach reports into one that exists. Which one you get never depends on whether you passed a name.

Retries depend on the request, not on its method. transport.py retries on its own terms instead of leaving it to urllib3, because a POST is only safe to repeat if it carries its own id. Runs, epochs and test results do: epoch UUIDs are uuid5(run, epoch), and a repeat of a run or an epoch is answered 409. A test result is an evaluation, so a repeat of one is answered with the stored one (200), and only a different result under its uuid is refused (409). Benchmarks and configs do not, so they are retried only when the request provably never reached the server: the connection failed, or the rate limiter or auth refused it before any handler ran. 500 is never retried, as Visin documents.

One sender thread. Writes depend on each other: a run is created before its epochs, and an epoch before its test results. Keeping them in order costs less than recovering when they arrive out of order.

The run is created synchronously. init returns only once the server has the run, or once it is known to be unreachable and the creation has been kept on disk. So an epoch can never arrive before its run.

Files are copied at the call. Scripts render each epoch's frames under the same names, so the original may hold the next epoch's image by the time a queued upload runs.

SIGTERM becomes SystemExit, but only if nobody else handles it. Otherwise a scheduler's termination skips atexit, and the run loses its queued epochs and stays "running" forever.

No default server. Unset means disabled. A hard-coded address once sent a copied script's runs to somebody else's Visin.

The API contract

tests/contract/test_openapi.py runs every public entry point against a fake server and checks each request against Visin's published OpenAPI spec. A request that fails its schema, hits a path that no longer exists, or carries a field the server would silently strip, fails the test. CI runs it against Visin's main branch weekly, so an API change that would break this package shows up without anyone having to remember it.