⚠️ Under construction. APIs, config shape, and server endpoints are still in flux. Not yet recommended for production deployments.
A daemon that watches instrument directories and attaches matching files to items in one or more datalab instances. The daemon extracts a datalab item_id from each file path with a regex, creates the item on the server if it doesn't already exist, and uploads the file to it — replacing in place if a same-named attachment is already there. Network traffic is outbound only; no inbound firewall rules are needed on the instrument PC.
The daemon runs a single-threaded tick() loop that drives three independent scan tiers per watched path plus an attach pass:
- Cold scan (default: every 24 h) — full directory walk; ground-truth reconciliation against the state DB.
- Warm scan (every 1 h) — directory-mtime-aware walk; discovers new files in active subtrees, skips per-file stats in cold subtrees.
- Hot scan (every 60 s) — re-stats only files modified in the last
hot_windowseconds; cheap and frequent. - Attach pass (every 20 min) — for every file pending in state with an
item_idextracted, ensure the datalab item exists then upload the file (withreplace_file_idset if a same-named attachment is already there).
Each tier writes its findings into a local SQLite state DB; the attach pass drains whatever is pending. Cold can be disabled (cold_interval: null) for write-once archives.
Either clone this repository and uv pip install . (or add to your
pyproject.toml), or use the standalone PyInstaller executable published for
each release on GitHub for your platform.
Generate a config template:
datalab-beholder init
This writes ~/.datalab-beholder/config.yaml. Edit it to point at your datalab instance and the directories you want to watch:
datalabs:
- name: "main"
url: "https://datalab.example.org"
api_key: "your-api-key-here" # or omit and use a <PREFIX>_DATALAB_API_KEY env var
watched_paths:
- kind: "local"
path: "/mnt/instrument/echem"
name: "digibat"
datalab: "main"
item_type: "cells"
include_patterns: ["*.mpr"]
id_patterns:
# `\D*\.mpr$` anchors the capture on the *last* digit run before
# the extension, so subdirectories of arbitrary depth are fine.
- "^(?P<group_id>P[0-9]{3,})/.*?(?P<item_id>[0-9]+)\\D*\\.mpr$"
# Optional templates that turn capture groups into the values the
# daemon actually sends to datalab. Resolved at scan time.
item_id_template: "{group_id}-{item_id}"
# collection_id_template: "{group_id}"
sync:
metadata_interval: 1200 # attach pending files every 20 minutes
log_level: infoMultiple datalabs entries are supported; each watched_paths[].datalab field references one by name. API keys are resolved by the underlying datalab client, which transparently checks <PREFIX>_DATALAB_API_KEY env vars (where <PREFIX> matches the deployment's identifier prefix) when no literal key is provided.
id_patterns is a list of Python regexes with named capture groups. Each file path (relative to the watched root) is tested against the patterns; the first match wins, and its captured groups become the file's identity for the rest of the pipeline. Allowed group names are:
item_id— required in every pattern; the datalab item the file attaches to.group_id— optional; passed asgroup_idswhen the daemon creates the item (access control).collection_id— optional; passed ascollection_idswhen the daemon creates the item.
Files that don't match any pattern are skipped silently. Set item_type to let the daemon create items that don't yet exist; without it the daemon only attaches to items that are already there.
item_id_template and collection_id_template are optional Python str.format strings that compose the values actually sent to the server out of the regex's capture groups. They're resolved at scan time, so the resolved id lands in the state DB and is what the scan CLI prints — what you see is what the daemon will create.
Example: id_patterns: ["^(?P<group_id>P[0-9]+)/(?P<item_id>[0-9]+)-.*\\.mpr$"] with item_id_template: "{group_id}-{item_id}" turns P042/7-cycle.mpr into a request for an item called P042-7.
If a template references a capture group the regex didn't produce, the file is skipped with a warning rather than crashing the attach pass.
The state DB records each watched path's id_patterns, item_id_template and collection_id_template. If any of them differ on the next start, the daemon logs a warning and discards that path's local state (other watched paths are untouched), then rescans from scratch:
- every matching file is re-attached under its new ids; a file that ends up on the same item under the same name is a no-op on the server, but its content is still sent once,
- files that now map to a different item are attached there, and their old attachments are left in place (beholder never deletes anything on the server),
- files that no longer match any pattern simply stop being tracked.
Run datalab-beholder dry-run after editing the patterns to preview exactly what the reset will do; status also flags a pending reset.
start is the default command, so the bare invocation runs the daemon:
datalab-beholder
To point at a non-default config:
datalab-beholder --config /path/to/config.yaml
(Equivalent to datalab-beholder start --config ....)
Creates a config template at ~/.datalab-beholder/config.yaml (or at --path).
datalab-beholder init --path ./my-config.yaml
Starts the daemon. Blocks until interrupted (Ctrl+C / SIGTERM). This is the default command — running datalab-beholder with no subcommand is equivalent.
datalab-beholder start [--config PATH] [--log-level debug|info|warning|error]
Simulates one full scan + attach pass without changing anything — no uploads, no item/block creation, no writes to the local state DB (it is opened read-only; a missing one is treated as "everything is new"). The server is only queried with GETs to work out what a real run would do: which items would be created, which files uploaded or replaced, and which blocks created. A summary is printed at the end.
datalab-beholder dry-run [--config PATH] [--log-level debug|info|warning|error]
At --log-level debug, every file that doesn't match is logged with the reason (excluded by which pattern, failed include_patterns, no id_pattern matched) — the fastest way to debug a config against a real directory tree.
An unreachable datalab is not fatal: the scan and pattern report still run, pending files are listed as "would attach (server state unknown)", and the connection failure is logged as an error. A watched path that cannot be scanned (missing or not a directory) is fatal — a mistyped path would otherwise read as "nothing to do".
One-off directory scan — useful for testing your patterns and id templates before running the daemon. With --config, scans every watched_paths[] entry and prints one JSON object per line per path; without --config, takes ad-hoc args.
# Use the same config the daemon would use — handy for verifying that
# id_patterns + templates resolve to what you expect.
datalab-beholder scan --config ./config.yaml . --pretty
# Or ad-hoc:
datalab-beholder scan /mnt/instrument/data \
--name "XRD-Lab-A" \
--include "*.raw" --include "*.csv" \
--exclude "*.tmp" \
--max-depth 3 \
--pretty
The JSON includes each entry's resolved ids dict (capture groups + templated item_id/collection_id) — that's exactly what the daemon will use.
Shows the current state database and recent sync history.
datalab-beholder status [--config PATH]
Launches a small Tkinter status GUI showing connection state per configured datalab, recent activity, and a settings editor for the YAML config. Driven by the same tick() loop as the CLI daemon.
datalab-beholder gui [--config PATH]
For deployment onto instrument PCs without a Python install, a single-file executable can be built with PyInstaller using the bundled beholder.spec:
uv run pyinstaller beholder.spec
The resulting binary in dist/ launches the GUI and reads config.yaml from the directory next to the executable. Pre-built Windows binaries are produced by the project's CI workflow.
PyInstaller does not cross-compile — a Windows binary must be built on Windows. To build binaries for an arbitrary commit without tagging a release, trigger the CI workflow manually (Actions → CI → Run workflow, or gh workflow run ci.yml --ref main -f ref=<sha>); manual runs upload the Linux/Windows/macOS binaries as downloadable workflow artifacts.
| Section | Field | Default | Description |
|---|---|---|---|
datalabs[] |
name |
(required) | Unique label referenced by watched_paths[].datalab |
datalabs[] |
url |
(required) | URL of the datalab instance |
datalabs[] |
api_key |
(optional) | API key; if omitted, resolved from env by the datalab client |
datalabs[] |
elevate_permissions |
false |
Admin keys only: read other users' items in datalab's super-user mode |
watched_paths[] |
path |
(required) | Directory to watch |
watched_paths[] |
name |
(required) | Label shown in the datalab UI |
watched_paths[] |
datalab |
(required if >1 datalab) | Name of the datalab instance to push to |
watched_paths[] |
include_patterns |
["*"] |
Glob patterns for files to include |
watched_paths[] |
exclude_patterns |
[] |
Glob patterns for files to exclude |
watched_paths[] |
id_patterns |
[] |
Regexes with named item_id/group_id/collection_id groups |
watched_paths[] |
item_id_template |
null |
str.format template, e.g. "{group_id}-{item_id}" |
watched_paths[] |
collection_id_template |
null |
str.format template for the collection id |
watched_paths[] |
item_type |
null |
datalab item type; required to auto-create missing items |
watched_paths[] |
max_depth |
10 |
Max directory traversal depth |
watched_paths[] |
scan.hot_interval |
60 |
Seconds between hot scans (recently-modified file re-stat) |
watched_paths[] |
scan.warm_interval |
3600 |
Seconds between warm scans (directory-mtime walk) |
watched_paths[] |
scan.cold_interval |
86400 |
Seconds between cold scans (full walk); null disables |
watched_paths[] |
scan.hot_window |
86400 |
"Recent" cutoff (s) for hot-scan eligibility |
sync |
metadata_interval |
1200 |
Seconds between attach passes |
reset_scan_clocks_on_startup |
false |
Clear the stored per-path scan timestamps on startup, forcing a cold scan on the first tick | |
log_level |
info |
Logging verbosity | |
state_db |
next to package/exe | Path to the local SQLite database |
The 0.1.x version ships only the direct-attach mode described above. A second mode is planned for a future release:
- Metadata-exposure mode — surface every matching file in the datalab UI as a browsable listing without uploading anything. Users browse the listing and request specific files on demand; the daemon then uploads only the requested ones. Useful when watched directories are large or contain files that shouldn't be uploaded by default. This requires server-side endpoints that don't yet exist on the datalab side, and will land alongside them.
Other things on the list:
- SSH and cloud-storage watched paths. The config schema already discriminates on
kind(local/ssh/cloud); onlylocalis implemented today.
Issues and design discussion welcome — file them at github.com/datalab-org/datalab-beholder/issues.
git clone https://github.com/datalab-org/datalab-beholder.git
cd datalab-beholder
uv sync
uv run pytest -v
See DESIGN.md for the original design notes and motivation.
By default beholder can only see items its API key's own account owns or has been shared on, so a file whose item_id belongs to someone else is skipped.
Setting elevate_permissions: true on a datalab entry makes the daemon send datalab's admin super-user flag (?sudo=1) on its reads, so it can look up and attach to any item on the instance:
datalabs:
- name: "main"
url: "https://datalab.example.org"
api_key: "an-admin-users-api-key"
elevate_permissions: trueThis requires the configured key to belong to an admin account. For a normal user the flag is ignored server-side and nothing changes. Writes are unaffected: datalab already grants admins full write access without an opt-in, so the flag is only ever sent on GETs.
Two caveats:
- Uploaded files are recorded with the admin's account as their creator, not the item owner's. The owner still sees, downloads and can delete the file (file access is inherited from the item), but the provenance reads as though the admin attached it.
- This only covers attaching to items that already exist. If beholder falls
back to creating an item, that item is owned by the admin account and the
intended user will not see it — so pair this with
group_id/collection_idcapture groups, pre-create the items or disable auto-creation (viaitem_type: null) to avoid that.
Longer term this is intended to be replaced by instrument-scoped API keys issued by an admin, rather than borrowing a human admin's key.
