Metadata-Version: 2.4
Name: labcat
Version: 0.1.0.dev1
Summary: A small test-and-measurement device catalog: what a device is, where it is, and its history
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pyyaml>=6.0
Provides-Extra: gui
Requires-Dist: PyQt5>=5.15; extra == "gui"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Dynamic: license-file

# labcat

A small test-and-measurement device catalog: what a device is, where it
currently is, and the full history of how it got there. Built to solve
one specific problem: manually re-typing device IDs, models, and fixture
positions into measurement scripts is tedious and an easy place to make
a mistake (mounting two devices in the same fixture position without
noticing, for instance).

This is a *reference/inventory* tool, deliberately kept separate from
[nebula](../nebula) (which handles measurement data provenance). labcat
answers "what is this device and where is it"; nebula answers "what
produced this data file." A measurement script typically asks labcat for
device info, then hands the result to nebula as plain structured data:

```python
dev = labcat.load().get("R4C1W8")
s.write_meta_for(fn, inputs={"device_id": dev.id, "model": dev.attributes["model"],
                              "position": dev.current_position()})
```

## Data model

One YAML file is the source of truth — small, human-readable, and meant
to be git-tracked (unlike a nebula archive, which should *not* go in
git; see that project's README for why the two have opposite storage
answers).

Each device has:

- **`attributes`** — a small freeform dict for things that rarely
  change and don't follow a predictable pattern: model, manufacturer,
  hand-written design notes. Direct overwrite, no history tracked.
- **`events`** — a generic, typed, timestamped log. Current state
  (current position, current status, anything else) is always
  *derived* from this log, never stored redundantly, so there's exactly
  one place that can be wrong.
  - `placement` is the built-in **span**-type event (`start`/`end`) —
    this is what `mount`/`unmount`/`move` write.
  - `status` is the built-in **instant**-type event (`timestamp`/`value`).
  - Anything else — calibration runs, ownership changes, whatever comes
    up later — is just a new `type` string via `log_event()` / `labcat log`.
    No schema change required to add a new kind of thing to track.

```yaml
R4C1W8:
  attributes:
    model: "v6"
    notes: "15-2-15"
  events:
    - type: placement
      position: 6
      start: "2026-08-10"
      end: "2027-08-14"
    - type: status
      timestamp: "2027-08-20"
      value: "in-progress"
```

## Where the file lives

`labcat.load()` resolves the catalog path from, in order: an explicit
path, `$LABCAT_CATALOG`, or `~/.labcat/catalog.yaml`. Point it at a file
inside a git repo you sync across machines — that's the whole
multi-machine story; labcat itself has no networking.

## Safety

- **Position conflicts are a hard refusal by default.** `mount()`
  raises `PositionOccupied` if another device already occupies that
  position, and `AlreadyMounted` if the device you're mounting is
  already somewhere else. Pass `force=True` to override either check.
  This refusal is the actual point of the tool — it's the "centralized
  check against errors" a hand-maintained `.env` file can't give you.
- **File-locked, reload-then-write mutations.** Every mutating call
  (`mount`, `unmount`, `move`, `log_event`, `add_device`) takes a local
  file lock, re-reads the catalog fresh from disk, applies the change,
  and writes — so two processes on the same machine racing on a write
  can't silently clobber each other. This does *not* cover true
  concurrent edits from two different machines; that's git's job (the
  YAML is small and diffable specifically so a merge conflict here is
  something you can read and resolve by eye).

## CLI

```
labcat init [PATH] [--force]
labcat add <device_id> [--model M] [--notes N] [--attr key=value ...]
labcat set <device_id> [key=value ...] [--model M] [--notes N] [--append key=value] [--remove key]
labcat mount <device_id> <position> [--date YYYY-MM-DD] [--force]
labcat unmount (<device_id> | --all) [--date YYYY-MM-DD]
labcat move <device_id> <position> [--date YYYY-MM-DD] [--force]
labcat log <device_id> <type> [key=value ...] [--date YYYY-MM-DD]
labcat status <device_id> <value> [--date YYYY-MM-DD]
labcat show <device_id>
labcat list [--mounted]
labcat whats-in [<position>]
labcat history <device_id> [--type TYPE]
labcat where
```

Every command accepts a global `--catalog PATH`, placed right after
`labcat` and before the subcommand, which overrides `$LABCAT_CATALOG` for
that one invocation:

```
labcat --catalog /path/to/other.yaml list
```

`init` creates an empty catalog file on disk (refuses to overwrite an
existing one unless `--force`) — not required, since `add`/`mount`/etc.
create the file on first write, but useful when you want something to
commit to git before you've added any devices. It takes an optional
path (`labcat init ~/repos/lab-devices/catalog.yaml`); when that isn't
the default location, it prints the line to add to your shell startup
file — in your shell's own syntax — to make it the default.

`labcat where` prints which catalog file is in use and which of the
three sources chose it. Worth knowing about: an unset `$LABCAT_CATALOG`
and a working catalog aren't a contradiction, they just mean the
built-in default is in play, and `where` is how you tell.

`labcat whats-in` with no position lists every occupied position — the
whole current layout. `labcat unmount --all` tears down every mounted
device in a single write. `set` edits attributes on a device that already exists — the clearest
entry point for "I want to start tracking a field I didn't capture when
I first added this device." It covers the whole attribute lifecycle:

```
labcat set R4C1W8 foundry=fab-b          # set or overwrite
labcat set R4C1W8 --model v7             # same, for model/notes
labcat set R4C1W8 --append notes="re-bonded"
labcat set R4C1W8 --remove foundry
```

Combine them freely in one command; they apply as set, then append,
then remove, in a single write, so a bad `--remove` can't leave a
device half-updated. `--append` joins onto the existing value with
`"; "` (`--sep` changes it) and plain-sets an attribute that isn't
there yet. `labcat show` displays them. `add --update` remains the way
to edit *while* creating-or-updating in one step; `set` is for a device
you know already exists (and says so loudly if it doesn't). See `examples/cli_walkthrough.txt` for a full narrated
session covering all three of these.

## Python API

```python
import labcat

cat = labcat.load()
cat.add_device("R4C1W8", attributes={"model": "v6", "notes": "15-2-15"})
cat.mount("R4C1W8", position=6)

dev = cat.get("R4C1W8")
dev.current_position()          # 6
dev.attributes["model"]         # "v6"
dev.history()                   # full event log, oldest first
```

Picker for measurement scripts (optional PyQt5 dependency, not imported
by default):

```python
from labcat.picker import pick_device

dev = pick_device()             # pops a picker of currently-mounted devices
# or, for non-interactive/batch use:
dev = pick_device(non_interactive_id="R4C1W8")
```

## Examples

`examples/` has a narrated CLI walkthrough (`cli_walkthrough.txt`,
covering `--catalog`, `init`, and adding new fields to an existing
device) and three runnable Python scripts: a basic device lookup
(`lookup_device.py`), a reverse lookup by fixture position
(`whats_in_position.py`), and the pattern for resolving a device at the
start of a measurement script and handing its info to nebula
(`measurement_script_pattern.py`).

## Status

Core catalog model, CLI, and picker scaffold implemented and unit
tested (device lifecycle, position-conflict refusal and `force`
override, generic event logging, locking against same-machine race
conditions). Not yet tested against a real Qt event loop or a real
multi-machine git sync workflow.
