Local-first research data management — schemas, records, files, and workflow automation in one place
Project description
civex
A command-line research data management system. Define schemas, collect records into datasets, attach files, and run data processing workflows — all locally, with an optional HTTP server and web UI.
Contents
- Installation
- Quick start
- Project initialisation
- Schemas
- Datasets
- Records
- Workflows
- Plugins
- Web UI & server
- Logging & telemetry
- Remote sync & CivexHub
- PostgreSQL
- Architecture
Installation
Recommended: pipx (installs once, available in any terminal)
pipx install "civex[server,workflows]"
If you don't have pipx:
pip install pipx
pipx ensurepath # adds pipx-managed commands to PATH — open a new terminal after this
After installation, civex is available in any terminal without activating a virtual environment.
Upgrade
pipx upgrade civex
Alternative: pip (inside a virtual environment)
pip install "civex[server,workflows]"
Quick start
civex init # initialise a project here
civex schema create trial --description "A single experimental trial"
civex schema add-field trial subject --type string --required
civex schema add-field trial duration --type float
civex dataset create study-2024
civex record add --to study-2024
civex record find --in study-2024
civex serve # open http://localhost:8000
Project initialisation
civex init [PATH]
Creates a _civex/ directory at PATH (default: current directory):
_civex/
config.toml # database URL and optional remote config
civex.db # SQLite database (default)
objects/ # content-addressed file storage (git-style)
workflows/ # YAML workflow definitions
plugins/ # user-written custom plugins
All other commands walk up from the current directory to find _civex/, the same way git finds .git/.
Schemas
A schema defines the structure of records — fields, types, and whether they are required. Schemas can inherit fields from a parent schema.
civex schema create <name> [--description TEXT] [--parent SCHEMA]
civex schema list
civex schema show <name>
civex schema add-field <schema> <field> --type TYPE [--required]
civex schema delete <name>
Field types: integer float string boolean file
Example — inheritance:
civex schema create experiment --description "Common fields"
civex schema add-field experiment subject --type string --required
civex schema add-field experiment date --type string
civex schema create trial --parent experiment --description "A single trial"
civex schema add-field trial duration --type float
civex schema add-field trial condition --type string
civex schema show trial
# Field Type Required Source
# duration float trial
# condition string trial
# subject string yes ↑ experiment
# date string ↑ experiment
Own fields shadow parent fields of the same name. Inheritance is resolved recursively, so chains of any depth work.
Datasets
A dataset is a named container for records. Records within a dataset can have different schemas (e.g. a dataset can hold both trial and experiment records).
civex dataset create <name> [--description TEXT]
civex dataset list
civex dataset show <name>
civex dataset delete <name> [--yes]
Example:
civex dataset create pilot-study --description "Pilot run, n=10"
civex dataset show pilot-study
# pilot-study
# Records 0
# Pilot run, n=10
Records
Records are individual data entries within a dataset. record add prompts for each field in the schema (including inherited fields). Required fields cannot be skipped.
civex record add --to <dataset> [--schema SCHEMA]
civex record show <id>
civex record update <id>
civex record find --in <dataset> [--where field=value ...] [--limit N]
civex record delete <id> [--yes]
Adding a record:
civex record add --to pilot-study --schema trial
# duration (float) []: 45.3
# condition (string) []: A
# subject (string) [required]: S01
# date (string) []: 2024-03-15
# Added record 0c45e37f-...
Filtering:
civex record find --in pilot-study --where condition=A
civex record find --in pilot-study --where condition=A --where subject=S01
civex record find --in pilot-study --limit 10
Multiple --where conditions are AND'd. Filtering runs in Python against the JSON record data.
Short IDs: commands accept a short prefix of the record UUID, like git:
civex record show 0c45e37f
civex record update 0c45e37f
civex record delete 0c45e37f --yes
File fields: for fields of type file, provide a local file path when prompted. The file is read, hashed (SHA-256), and stored content-addressed in _civex/objects/. Identical files are stored once.
civex schema add-field trial raw_data --type file
civex record add --to pilot-study --schema trial
# duration (float) []: 30.0
# condition (string) []: B
# raw_data (file) []: /path/to/eeg_session_01.csv
# subject (string) [required]: S04
The record stores a reference {sha256, filename, size} — the bytes live in _civex/objects/<sha256[:2]>/<sha256[2:]>.
Workflows
A workflow is a YAML file in _civex/workflows/ that defines a pipeline of plugin steps. Workflows are version-controllable, portable, and local-first — they run on your machine against your data.
civex workflow list
civex workflow run <name> --record <id>
YAML format
name: import-csv-records
description: Create one record per row from a CSV file field
steps:
- id: load
plugin: civex.load_file
config:
field: raw_data # field name on the trigger record
- id: parse
plugin: civex.load_csv
inputs:
bytes: load.bytes # step_id.output_name
config:
delimiter: ","
- id: create
plugin: civex.rows_to_records
inputs:
table: parse.table
config:
dataset: processed-trials
field_mapping: # csv_column → schema_field
subject_id: subject
duration_ms: duration
Each step's inputs references the output of an earlier step using step_id.output_name notation. The executor resolves these in topological order, so steps can appear in any order in the file as long as the dependency graph is acyclic.
End-to-end example
# 1. Prepare schemas and datasets
civex schema create experiment
civex schema add-field experiment subject --type string --required
civex schema add-field experiment raw_data --type file
civex dataset create study
civex dataset create results
# 2. Add a trigger record that holds the CSV file
civex record add --to study --schema experiment
# subject (string) [required]: batch-01
# raw_data (file) []: /path/to/data.csv
# 3. Create the workflow
cat > _civex/workflows/import-rows.yaml << 'EOF'
name: import-rows
description: Create one record per CSV row
steps:
- id: load
plugin: civex.load_file
config:
field: raw_data
- id: parse
plugin: civex.load_csv
inputs:
bytes: load.bytes
- id: create
plugin: civex.rows_to_records
inputs:
table: parse.table
config:
dataset: results
field_mapping:
subject: subject
EOF
# 4. Run
civex workflow run import-rows --record <id>
civex record find --in results
Plugins
Plugins are the individual steps within a workflow. Each plugin takes named inputs, a typed config, and a workflow context, and returns named outputs.
Built-in plugins
| ID | Category | Inputs | Config | Outputs |
|---|---|---|---|---|
civex.load_file |
data-sources | — | field: str |
bytes, filename, sha256 |
civex.load_csv |
data-sources | bytes |
delimiter, encoding |
table (DataFrame) |
civex.get_field |
data-access | — | field: str |
value |
civex.save_field |
outputs | value |
field: str |
— |
civex.rows_to_records |
outputs | table |
dataset, field_mapping |
created (int) |
civex.load_csv and civex.rows_to_records require the [workflows] extra (pandas).
Writing a custom plugin
Create a Python file in _civex/plugins/. It must define a class named Plugin that subclasses BasePlugin:
# _civex/plugins/normalise.py
from typing import Any
from pydantic import BaseModel
from civex.plugins.base import BasePlugin, WorkflowContext
class Plugin(BasePlugin):
id = "my.normalise"
name = "Normalise Values"
category = "transformations"
class Config(BaseModel):
column: str
factor: float = 1.0
def run(
self,
inputs: dict[str, Any],
config: Config,
ctx: WorkflowContext,
) -> dict[str, Any]:
df = inputs["table"].copy()
df[config.column] = df[config.column] * config.factor
return {"table": df}
Use it in a workflow:
steps:
- id: normalise
plugin: my.normalise
inputs:
table: parse.table
config:
column: duration
factor: 0.001
Custom plugins are discovered automatically from _civex/plugins/*.py when civex workflow run executes. They are never sent to a server.
WorkflowContext
The ctx argument gives plugins access to the trigger record and the data layer:
ctx.record # RecordDTO — the record that triggered the workflow
ctx.dataset # DatasetDTO — the dataset it belongs to
ctx.get_file(sha256) # bytes — retrieve a stored file by hash
ctx.update_record(data) # write field values back to the trigger record
ctx.create_record(dataset_name, data) # create a new record in any dataset
Web UI & server
civex serve [--host HOST] [--port PORT]
Starts a local HTTP server (default http://127.0.0.1:8000) with:
- A React web UI for browsing schemas, datasets, records, workflows, and jobs
- A full REST API at
/api/(OpenAPI docs at/docs) - An interactive civex shell in the browser (Terminal tab)
- Import/export YAML dumps from the Datasets page
The server requires the [server] extra (included in the pipx install command above).
Security model
civex serve is local-first, like git — it runs for a single trusted user on your own machine and has no authentication. By default it binds to loopback (127.0.0.1) and a middleware guards the two attack classes that still apply to a localhost server open in a browser:
- DNS rebinding — requests whose
Hostheader isn't a loopback name are rejected. - CSRF — state-changing requests (
POST/PUT/PATCH/DELETE) carrying a non-loopbackOriginare rejected.
To expose the server to other machines you must opt in explicitly:
civex serve --host 0.0.0.0 --allow-remote
--allow-remote stands the guard down and prints a warning. Because there is still no authentication, only do this on a trusted network behind a reverse proxy or firewall.
Logging & telemetry
civex writes structured logs locally and can optionally send crash reports to an error-tracking service. The two are separate:
- Logging — a local record of what happened. On by default. Stays on your machine.
- Telemetry — crash reports sent off your machine so the maintainer can see errors across installs. Off by default, opt-in only.
Logging (default)
When you run civex serve, logs go to two places:
- Console — human-readable, colored output when attached to a terminal; single-line JSON when piped or captured.
- File — rotating JSON lines at
_civex/logs/civex.log(5 MB × 3 backups), ideal for grepping or shipping to a log tool.
Every HTTP request gets a correlation id (returned in the X-Request-ID response header and attached to every log line for that request). Values that look like credentials (api_key, token, authorization, password, …) are automatically redacted to *** before anything is written. The _civex/logs/ directory is git-ignored.
Set the level from the CLI (overrides config):
civex serve --log-level DEBUG # DEBUG | INFO | WARNING | ERROR
Or configure it in _civex/config.toml:
[logging]
level = "INFO" # default INFO
json_console = false # omit for auto (color on a TTY, JSON otherwise)
to_file = true # write _civex/logs/civex.log
Inspect the log file directly:
tail -f _civex/logs/civex.log
cat _civex/logs/civex.log | jq 'select(.level == "error")' # errors only
Error responses
Expected errors (not found, validation, conflicts) return a clear message and the right HTTP status. Unexpected server errors return a generic 500 {"detail": "Internal server error", "request_id": "..."} — the full traceback is written to the logs only, never leaked to the client. Quote the request_id when reporting a problem to find the matching log line.
Telemetry (opt-in crash reporting)
Nothing is sent off your machine unless you turn this on. It uses Sentry and reports only exception stack traces (no request bodies, send_default_pii=False, no performance tracing).
-
Install the extra:
pip install "civex[telemetry]" # or: pipx inject civex sentry-sdk
-
Provide a DSN — either in
_civex/config.toml:[telemetry] dsn = "https://<key>@<org>.ingest.sentry.io/<project>" environment = "local"
or via an environment variable (takes effect without editing config):
export CIVEX_SENTRY_DSN="https://<key>@<org>.ingest.sentry.io/<project>"
With no DSN (the default), telemetry is a no-op even if the extra is installed.
Remote sync & CivexHub
CivexHub is a self-hosted server that stores civex repositories centrally and lets multiple clients push and pull data. It exposes an HTTPS API and an SSH interface — SSH is the recommended transport for local development.
Transport URLs
| Transport | URL format | When to use |
|---|---|---|
| SSH | ssh://user@host:port/owner/repo |
Local dev, Docker; no TLS cert needed |
| HTTPS | https://hub.example.com/owner/repo |
Production deployments behind a reverse proxy |
Setting the remote on the client
civex remote set ssh://alice@localhost:2222/alice/myrepo
civex push # send local commits to the hub
civex pull # fetch hub commits into the local project
The remote URL is stored in _civex/config.toml and is read by every push/pull.
CivexHub server — SSH setup
CivexHub's Docker image includes OpenSSH. SSH is configured automatically: no manual sshd_config editing is required.
How it works under the hood:
sshdis started alongside the civexhub HTTP process inside the container.- Every incoming SSH connection is forced through
civexhub-shell, a thin command dispatcher that readsSSH_ORIGINAL_COMMANDand routes it to transfer-pack, receive-pack, or object fetch/put. - Host keys are generated on first boot (
ssh-keygen -A) and survive restarts as long as the container is not recreated. - Authentication is public-key only — password auth is disabled.
sshdlooks up authorised keys by callingcivexhub-keys <username>, which queries the hub's database and prints the keys inauthorized_keysformat with the forced-command prefix already applied.
Start the hub with Docker Compose:
cd civex-hub
docker compose up -d --build
This exposes:
8001— HTTPS API and web UI2222— SSH (use2222on the host to avoid conflicting with any SSH daemon already running on the host)
CivexHub server — adding SSH keys
SSH keys are managed through the hub web UI or API. There is no server-side key file to edit.
Via the web UI:
- Open
http://localhost:8001and log in. - Go to Settings → SSH keys.
- Paste your public key (
~/.ssh/id_ed25519.pubor similar) and give it a title. - Click Add key.
Via the API (useful for scripting):
# 1. Get a token
TOKEN=$(curl -s -X POST http://localhost:8001/api/v1/auth/token \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"secret"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
# 2. Upload your public key
curl -s -X POST http://localhost:8001/api/v1/settings/ssh-keys \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"title\":\"my-laptop\",\"public_key\":\"$(cat ~/.ssh/id_ed25519.pub)\"}"
Keys can be listed and deleted via the same UI or via GET/DELETE /api/v1/settings/ssh-keys.
Client — first push/pull walkthrough
# 1. Initialise a local civex project (if you haven't already)
civex init
civex schema create site --description "Camera trap site"
civex schema add-field site name --type string --required
civex dataset create sites
civex record add --to sites --schema site
# 2. Point it at a hub repository
civex remote set ssh://alice@localhost:2222/alice/camera-traps
# 3. Push
civex push
# Pushed 1 commit (3 records, 0 files)
# 4. On another machine, pull
civex pull
# Pulled 1 commit (3 records, 0 files)
No repository needs to be pre-created on the hub — push creates it on first contact.
SSH host key verification
The first time you push to a hub over SSH, your SSH client will ask you to verify the host key:
The authenticity of host '[localhost]:2222 ([127.0.0.1]:2222)' can't be established.
ED25519 key fingerprint is SHA256:...
Are you sure you want to continue connecting (yes/no/[fingerprint])?
Type yes to add it to ~/.ssh/known_hosts. Subsequent pushes and pulls will connect silently. In CI or scripting contexts, add StrictHostKeyChecking=no in ~/.ssh/config for the hub host.
PostgreSQL
Starting a local PostgreSQL container
docker run -d \
--name civex-pg \
-e POSTGRES_USER=civex \
-e POSTGRES_PASSWORD=civex \
-e POSTGRES_DB=civex \
-p 5432:5432 \
postgres:16
# Stop / remove when done
docker stop civex-pg && docker rm civex-pg
Connecting civex to it
Edit _civex/config.toml:
[db]
url = "postgresql://civex:civex@localhost:5432/civex"
JSON fields (record.data) automatically upgrade to JSONB on PostgreSQL for indexed querying. Install the driver with pipx inject civex psycopg2-binary.
Index benchmark
A benchmark script is included that shows the query speedup from the composite B-tree and GIN indexes added to the records table. It seeds 100 000 records, measures query times before and after creating the indexes, and prints a comparison table with EXPLAIN ANALYZE output.
docker run -d \
--name civex-bench \
-e POSTGRES_USER=civex \
-e POSTGRES_PASSWORD=civex \
-e POSTGRES_DB=civex_bench \
-p 5432:5432 \
postgres:16
PG_URL=postgresql://civex:civex@localhost/civex_bench \
python tests/bench_indexes.py
docker stop civex-bench && docker rm civex-bench
Architecture
civex-service/
src/civex/
cli/ # Typer commands — thin: parse args → call service → format output
plugins/ # Plugin contract (BasePlugin, WorkflowContext) + built-ins + registry
workflows/ # YAML definition models + topological executor
services/ # Business logic: SchemaService, DatasetService, RecordService, FileService
repositories/ # Protocol interfaces + SQLAlchemy implementations
domain/ # Plain dataclasses (DTOs) and exceptions — no framework dependency
db/ # SQLAlchemy models and session factory
server/ # FastAPI app, routers, and static frontend assets
config.py # Project root discovery + config loading
context.py # AppContext factory (wires all repos + services)
Layer rules:
cli→services(viaAppContext). Never talks to repos or DB directly.plugins→WorkflowContextonly. Never imports SQLAlchemy or sessions.services→repositories/protocols(interfaces, not implementations). Never imports fromcli.repositories/local→db/models. The only layer that touches SQLAlchemy models.domainhas no imports from the rest of civex — it is always safe to import anywhere.
File storage mirrors the git object store: _civex/objects/<sha256[:2]>/<sha256[2:]>. Content-addressed and idempotent — the same file uploaded twice is stored once.
Workflow execution is git-hook-like: definitions are YAML files in _civex/workflows/, checked into version control alongside your data config. The executor runs a topological sort of steps, resolves step_id.output_name input references, and calls each plugin's run() in order.
License
civex is free to use for any purpose, including commercial use. Redistribution and modification are not permitted. See LICENSE for full terms.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distributions
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file civex-1.0.4-py3-none-any.whl.
File metadata
- Download URL: civex-1.0.4-py3-none-any.whl
- Upload date:
- Size: 397.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b6fa2c9017d3debc377e3860c83e0e5cbfffaf1d18aececa772df6133b5a6cc4
|
|
| MD5 |
539d62b9e1f17919f815659b60e23e91
|
|
| BLAKE2b-256 |
4c5d67b710f97a53184353fdbb3f2d27f8b3a8e4ac34b746c09336898f191763
|
Provenance
The following attestation bundles were made for civex-1.0.4-py3-none-any.whl:
Publisher:
release.yml on sullivan-james/civex-service
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
civex-1.0.4-py3-none-any.whl -
Subject digest:
b6fa2c9017d3debc377e3860c83e0e5cbfffaf1d18aececa772df6133b5a6cc4 - Sigstore transparency entry: 2180422275
- Sigstore integration time:
-
Permalink:
sullivan-james/civex-service@b78fbc3d41d92ecd5acc2acdf8eb837cdccd91e1 -
Branch / Tag:
refs/tags/v1.0.4 - Owner: https://github.com/sullivan-james
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@b78fbc3d41d92ecd5acc2acdf8eb837cdccd91e1 -
Trigger Event:
push
-
Statement type: