Initial version

This commit is contained in:
Tom Kasper
2026-08-30 20:44:00 +02:00
commit 9aa2da72b5
612 changed files with 64514 additions and 0 deletions
+441
View File
@@ -0,0 +1,441 @@
# LabVoice MCP Server — Implementation Plan
Status: **draft for review** (nothing below is built yet; `src/` is empty and
`vendor/elabapi-python` holds the vendored official `elabapi_python` client)
## 1. Goal
An MCP server exposing the eLabFTW REST API v2 as a small set of **aggregate tools**
designed for small/low-power models and voice-driven wetlab use.
Design decisions already made:
1. **Consumables live in the protocol template** — machine-readable JSON at the end of
the step text, hidden from the client UI in an HTML comment.
2. **Direct execution** — no preview/confirmation round. The point is hands-free
voice operation.
3. **Portable** — templates reference stable *semantic resource keys*, never eLabFTW
numeric IDs. The server installs on top of any existing eLabFTW instance with only
template edits (plus optional resource markers).
4. **Reads are dual-purpose** — the same read tools are exposed both as MCP tools and
over a plain read-only REST API. REST lets the mobile app render state without
spending model tokens; MCP lets the LLM fetch detail on demand. The edge LLM
receives only a **minimal handoff context** from the app (experiment id, user id,
current step id, device id, location id — on-device compute means a tight token
budget), so it pulls anything beyond the ids via the read tools.
5. **Templates are first-class** — new experiments are created from a template, chosen
interactively via a template-listing/search tool (short descriptions, semantic
ranking).
## 2. Architecture
```text
mobile app ──REST: reads for the UI (no LLM)──┐
│ │
│ voice + minimal handoff │
│ context (ids only) │
▼ │
small LLM ──MCP: reads on demand, aggregates──┤
(stdio / streamable HTTP) │
▼
┌──────────────────────────────┐
│ mcp_server │
│ ├── tools.py │ small tool surface, compact results
│ ├── rest.py │ read-only REST mirror of the reads
│ ├── templates.py │ template search index + creation
│ ├── workflows/ │ aggregate operations (the point)
│ │ ├── protocol.py │ step completion saga
│ │ └── inventory.py │ allocation, unit handling
│ ├── resolve.py │ resource_key → item id mapping
│ ├── annotate.py │ annotation parse/validate
│ ├── elabftw.py │ async adapter over vendored elabapi_python
│ └── journal.py │ SQLite operation journal (sagas)
└──────────────────────────────┘
│ HTTPS, Authorization: <api key>
▼
eLabFTW REST API v2
(client: vendor/elabapi-python)
```
Layers, top to bottom: **MCP tools / REST routes → workflow services →
elabapi_python adapter → eLabFTW**. The read tools are single implementations behind
two transports: `tools.py` (MCP, for the LLM) and `rest.py` (REST, for the app UI).
No generic `PATCH/POST/DELETE` passthrough tools — aggregate tools only, so a small
model cannot pick an unsafe raw mutation.
## 3. Module layout (`src/mcp_server/`)
```text
src/mcp_server/
__init__.py
__main__.py # entry point: stdio (default) or streamable-http
config.py # env-driven settings (pydantic-settings)
models.py # tool input/output schemas, compact result types
errors.py # typed errors → voice-friendly messages
elabftw.py # async adapter over the vendored elabapi_python client:
# Configuration/ApiClient setup, error normalization,
# sync calls wrapped with asyncio.to_thread
schemas.py # pydantic models for the eLabFTW entities we use
annotate.py # labvoice:v1 annotation parser + validator
resolve.py # resource key resolution (mapping store + markers)
allocate.py # container selection, unit whitelist/conversion
templates.py # experiment templates: description extraction, semantic
# ranking (embedding cache in SQLite), creation flow
journal.py # SQLite saga journal (idempotency + compensation)
workflows/
__init__.py
protocol.py # complete_next/complete/observation workflows
inventory.py # adjust_inventory, stock reads
setup.py # scan_instance, map_resource, validate_templates
tools.py # MCP tool registration (one function per tool)
rest.py # FastAPI: read-only REST mirror of the read tools
# (state rendering for the app UI; bearer-token protected)
server.py # FastMCP assembly, lifespan; mounts rest.py in http mode
vendor/elabapi-python/ # official generated client (package elabapi_python,
# sync urllib3) — vendored, not edited; imported as-is
```
Dependencies to add: `mcp` (official Python SDK), `pydantic-settings`,
`aiosqlite` (journal), `fastapi` + `uvicorn` (REST fast path), `fastembed`
(optional — semantic template ranking, unset ⇒ lexical fallback). The REST client
itself comes from the vendored `elabapi_python` package
(`elabapi_python.Configuration` with `host = ELABFTW_URL + /api/v2`, api-key auth
header); because it is synchronous, the adapter runs calls via `asyncio.to_thread`.
Python 3.12 (already pinned).
## 4. Protocol annotation format (portable)
Appended at the end of a template step body:
```html
<!-- labvoice:v1
{
"consumables": [
{"resource_key": "ethanol_absolute", "quantity": 2.0, "unit": "mL",
"allocation": "fifo"}
]
}
-->
```
Field rules:
| field | required | notes |
|-----------------|----------|----------------------------------------------------|
| `resource_key` | yes | slug; stable across instances — never an item id |
| `quantity` | yes | positive number consumed per execution |
| `unit` | yes | must be in the unit whitelist |
| `allocation` | no | `fifo` (default), `nearest_expiry`, `specific` |
| `container_id` | no | only with `allocation: "specific"`; local hint, ignored if it does not match the resolved resource |
| `optional` | no | `true` → skip (with a warning) if stock is missing |
Parser rules (`annotate.py`):
- Recognize only `<!-- labvoice:v1 ... -->`; take the **last** valid block.
- Malformed JSON or schema violations ⇒ `annotation_error`, surfaced by
`validate_protocol_template` and blocking step completion (never silently ignored).
- `quantity` may also be `null` with `"prompt_quantity": true` for steps where the
used amount varies — completion then **requires** the caller to supply quantities
(voice: “how much did you use?”), otherwise it fails with a clarification request.
- Visible step text is never modified by the server.
Unit whitelist (extensible in config): `μL, mL, L, mg, g, kg, μg`, `ea` (each).
Conversions only within the same dimension and only tested pairs (e.g. `mL↔L`,
`mg↔g↔kg↔μg`); anything else ⇒ clarification, never a guess.
## 5. Resource resolution (`resolve.py`)
`resource_key` → eLabFTW `items` id, resolved in this order:
1. **Local mapping store** (SQLite table): `resource_key → item_id`, written during
setup. Authoritative at execution time.
2. **Resource marker** — hidden comment in the resource body:
`<!-- labvoice:resource-key=ethanol_absolute -->`, auto-discovered by scanning.
3. **Configured matchers** at setup time only (CAS extra field, `custom_id`).
4. **Exact title match** — setup-time suggestion only, requires admin approval;
never applied silently during execution.
Ambiguous or missing mapping ⇒ execution stops with a voice-friendly clarification
listing candidate resources. No fuzzy matching at runtime, ever.
### Installation on an existing instance (setup workflow)
- `scan_instance` — walk `GET /items` (paginated), inventory containers, detect
markers, propose matches from title/CAS/custom_id.
- `map_resource` — bind `resource_key → item_id` (idempotent, upsert).
- `export_mapping` / `import_mapping` — JSON mapping file for porting between
instances; import proposes but still requires approval of ambiguous matches.
Mapping file example:
```json
{
"ethanol_absolute": {"cas": "64-17-5", "title": "Absolute Ethanol", "unit": "mL"}
}
```
## 6. MCP tool surface
Ten tools total. The read tools are dual-purpose: the mobile app calls them via the
REST mirror (§8) to render state with zero model tokens, while the edge LLM — which
receives only a minimal handoff context of ids from the app — calls the same reads
over MCP when it needs detail (e.g. `get_next_protocol_step` for the full step body
and stock preview).
Reads (safe, any API key):
| tool | purpose |
|-------------------------|--------------------------------------------------------|
| `find_experiments` | search by title/text/tag/custom id, compact results |
| `get_experiment_context`| metadata + unfinished steps + linked resources + recent comments, all compact |
| `get_next_protocol_step`| next unfinished step: text, parsed consumables, stock preview |
| `list_experiment_templates` | list/search templates: id, title, short description, tags (semantic ranking, §9) |
| `validate_protocol_template` | check annotations + mappings of a template/experiment |
Aggregates (mutating):
| tool | purpose |
|-----------------------------|-------------------------------------------------------------------------|
| `complete_next_protocol_step` | the headline tool — see §7 |
| `complete_protocol_step` | same, with explicit step id (for “redo step 3” voice commands) |
| `record_protocol_observation` | add a comment (+ optional step body edit) without completing anything |
| `create_experiment_from_template` | start a new experiment from a template (interactive flow, §9) |
| `adjust_inventory` | restock / correct a container, voice: “add 500 mL to …” |
Setup (admin, mutating only the local mapping store):
| tool | purpose |
|---------------|---------------------------------------------|
| `scan_instance`, `map_resource`, `export_mapping`, `import_mapping` | §5 onboarding |
Result shapes are deliberately small: every tool returns compact JSON (ids, titles,
quantities, statuses) — never a raw eLabFTW entity dump. Tool descriptions are one or
two short sentences (small-model friendly). Mutating tools return exactly what
changed, for text-to-speech readback:
```json
{
"ok": true,
"experiment_id": 123,
"step": {"id": 9, "body": "Add ethanol", "finished": true},
"consumed": [{"resource_key": "ethanol_absolute", "container_id": 12,
"amount": "2.0 mL", "remaining": "48.0 mL"}],
"comment_id": 77,
"next_step": {"id": 10, "body": "Incubate 30 min"}
}
```
## 7. `complete_next_protocol_step` workflow (direct execution)
Input: `{experiment_id, comment?, quantities?}` — nothing else.
1. `GET /experiments/{id}` + steps; select lowest-`ordering` unfinished step.
None left ⇒ explicit `protocol_complete` result.
2. Parse annotation (§4). No annotation ⇒ complete step + comment, skip inventory.
3. Resolve every `resource_key` (§5). Unresolvable ⇒ clarification result, no mutation.
4. `GET /{entity_type}/{id}/containers` per resource; filter by unit compatibility;
apply allocation policy (`fifo` = lowest container id with stock,
`nearest_expiry` needs an expiry extra field, configured).
5. Validate stock: total available ≥ required (unit-converted). Insufficient ⇒
clarification listing what is short; `optional` consumables are skipped with a warning.
6. Execute as a journaled saga (§10), in this order:
1. decrement each container (`PATCH .../containers/{subid}` `qty_stored`),
2. finish the step (`PATCH .../steps/{subid}` `{"action":"finish"}`),
3. post the comment (`POST .../comments`).
7. Re-read the step to verify; return compact confirmation + next step.
The model makes **one tool call**; all sequencing is server-side.
## 8. Read-only REST API (app fast path)
The read tools are single implementations behind two transports: `rest.py` exposes
them as REST endpoints so the mobile app renders state without spending LLM tokens,
while `tools.py` keeps them available over MCP for the LLM and standalone clients.
Mutations are **not** exposed over REST — they exist only as MCP tools, so every
mutation is journaled (§10).
- Auth: static bearer token (`LABVOICE_REST_TOKEN`) — the app is a trusted client;
per-device tokens are an open question (§15).
- Same compact result shapes and typed errors as the MCP tools
(`{"error": "clarification", "message": ...}`).
| endpoint | purpose |
|---------------------------------------|---------------------------------------------------------------|
| `GET /api/experiments?q=&limit=` | `find_experiments` |
| `GET /api/experiments/{id}/state` | full app state: title, unfinished steps, linked resources, next step + parsed consumables + stock preview |
| `GET /api/experiments/{id}/next-step` | just the next step (the hot path for voice) |
| `GET /api/templates?q=&limit=` | `list_experiment_templates` (§9) |
The `state` response is what the app renders (title, steps, stock, etc.). What the
app forwards to the LLM is deliberately **minimal** — a handoff context of ids only,
since LLM compute is on-device at the edge and the token budget is tight:
```json
{"experiment_id": 123, "user_id": 2, "step_id": 9,
"device_id": "bench-7", "location_id": "lab-2"}
```
When the model needs more than the ids — full step body, parsed consumables, stock,
comments — it calls the corresponding MCP read tool (same services that back the
endpoints above). REST serves the UI; MCP serves the model; one implementation of
each read.
## 9. Experiment templates: semantic search & creation
Voice flow ("start a new experiment for the PCR cleanup"):
1. `list_experiment_templates(query?, limit?)` — templates as
`{id, title, short_description, tags}`, ranked by semantic similarity when a
query is given (best match first).
2. The LLM reads the short descriptions back; the user picks one interactively.
3. `create_experiment_from_template(template_id, title?)` — `POST /experiments`
with the template id; returns `{experiment_id, title, first_step}`. eLabFTW
copies the template steps, so `labvoice:v1` annotations come along and the
step-completion workflow (§7) applies immediately.
Short description: first paragraph of the template description, truncated
(`LABVOICE_TEMPLATE_DESC_LIMIT`, default 200 chars). It doubles as readback text
for the LLM and as part of the search corpus.
Semantic ranking (`templates.py`):
- Corpus per template: title + tags + full description.
- Preferred backend: local tiny embedding model (`fastembed`, ONNX, CPU, ~30 MB,
`LABVOICE_EMBED_MODEL`); template vectors computed at scan time (or lazily on
first use) and cached in the SQLite database as `template_id → vector`,
refreshed when templates change.
- Fallback (no model configured): lexical scoring — weighted token overlap,
title > tags > description. Same interface, just dumber ranking.
- Only templates visible to the current API key are ever returned; no fuzzy
matching on ids.
Creation notes:
- A single `POST /experiments` — no multi-step saga; still journaled for audit
(§10).
- `title` optional — eLabFTW applies the template's default title format when
omitted.
- A created-but-unwanted experiment is archived via eLabFTW itself; this server
never deletes.
## 10. Saga journal & failure handling (`journal.py`)
eLabFTW has no cross-entity transactions, so every mutating workflow runs as a
journaled saga in SQLite (`LABVOICE_DB_PATH`, default `~/.labvoice/journal.sqlite`):
- Each execution gets an `operation_id`; journal rows record planned actions, their
status, and eLabFTW responses.
- Sub-actions are idempotent (re-read before write; finishing an already-finished
step is a no-op; decrement uses read-modify-write with a re-read check).
- On failure of step 6.2 or 6.3: **compensate** — restore decremented quantities
(PATCH back), then:
- success ⇒ return `reverted` result explaining what happened;
- compensation fails ⇒ mark `partial_failure` in the journal **and** post an audit
comment on the experiment describing exactly what is inconsistent; result tells
the user which container to check.
- Journal is also the audit log (who/when/what) and powers a future reconciliation tool.
## 11. Configuration & deployment
Env vars (all via `config.py`):
```text
ELABFTW_URL # https://eln.example.org (adapter sets host to this + /api/v2)
ELABFTW_API_KEY # read-only works for read tools; writes need can_write
ELABFTW_TIMEOUT=10 # per-request timeout seconds
ELABFTW_RETRIES=2
LABVOICE_DB_PATH=~/.labvoice/journal.sqlite
LABVOICE_REST_TOKEN=... # bearer token for the read-only REST API (§8)
LABVOICE_TEMPLATE_DESC_LIMIT=200
LABVOICE_EMBED_MODEL=... # optional; unset ⇒ lexical template ranking
LABVOICE_UNIT_WHITELIST=... # optional override
LABVOICE_EXPIRY_FIELD=... # extra-field name for nearest_expiry allocation
```
- Transports: **stdio** (default; local/phone use) and **streamable-http**
(`--transport http`, for a shared Raspberry-Pi-class deployment). In http mode a
single uvicorn process serves both the MCP app and the read-only REST API
(`rest.py`) — one deployment for app + LLM. `stateless_http` mode; no session
affinity needed.
- Health check = `GET /info` through the client (auth + reachability in one call).
- Packaging: uv project, console script `labvoice`; single Dockerfile (optional).
## 12. eLabFTW endpoints used
```text
GET /info
GET /experiments (search: q, tags, limit/offset)
GET /experiments/{id}
POST /experiments {"template": <id>, "title"?} (§9 creation)
GET /{entity_type}/{id}/steps
PATCH /{entity_type}/{id}/steps/{subid} {"action": "finish"}
GET /{entity_type}/{id}/comments
POST /{entity_type}/{id}/comments
GET /items (scan/search)
GET /items/{id}
GET /{entity_type}/{id}/containers
PATCH /{entity_type}/{id}/containers/{subid} {"qty_stored": ...}
GET /storage_units?hierarchy=true (location names for readback)
GET /experiments_templates (list/search templates, §9)
GET /experiments_templates/{id}
```
`entity_type` ∈ {`experiments`, `items`} only — templates are read, never mutated, by
this server (template editing happens in eLabFTW itself). The adapter (`elabftw.py`)
normalizes all client errors into typed errors (`auth_error`, `permission_error`,
`not_found`, `api_error`) with voice-friendly messages; never leaks the API key into
results or logs.
## 13. Testing & verification
- **Unit** (pytest, mocked `elabapi_python` API instances): annotation parser (valid/malformed/
last-block/prompt_quantity), resolver precedence + ambiguity, allocator (fifo,
splitting across containers, unit conversion, insufficient stock), journal
(idempotency, compensation, partial_failure), error normalization, template
ranking (golden queries for semantic + lexical fallback, description truncation),
creation from template (steps + annotations copied).
- **REST API**: FastAPI TestClient — bearer auth enforced, `state`/`next-step`
responses (snapshot tests).
- **MCP-level**: tool schema lint (small input schemas), `tools/list` snapshot test,
golden results for the headline workflow.
- **Integration** (optional, manual): disposable eLabFTW docker instance; script
creates experiment + template with annotations, maps resources, runs the saga,
asserts final state via the API.
- **Safety checks as tests**: read-only key must fail cleanly on every mutating tool;
immutable step → explicit error; double execution of same operation_id is idempotent.
## 14. Build order (PR-sized milestones)
1. `config.py`, `errors.py`, `models.py`, elabftw adapter over `elabapi_python` + schemas (read paths only)
2. `annotate.py` parser + `validate_protocol_template` tool
3. `resolve.py` mapping store + `scan_instance` / `map_resource` tools
4. read tools: `find_experiments`, `get_experiment_context`, `get_next_protocol_step`
5. `rest.py`: read-only REST mirror over the same services (bearer auth)
6. `journal.py` saga engine
7. `workflows/protocol.py`: `complete_protocol_step` → `complete_next_protocol_step`
→ `record_protocol_observation`
8. `templates.py`: `list_experiment_templates` (search index) +
`create_experiment_from_template`
9. `adjust_inventory`
10. MCP server assembly (stdio + http, REST mount), entry point, README, Dockerfile
11. integration script + docs (annotation authoring guide for template editors)
Milestones 1–5 are read-only and independently testable; the first mutating code
lands in milestone 7 on top of the journal (6).
## 15. Open questions (need your input before/at build time)
1. **Step selection** — strictly lowest `ordering` among unfinished steps, or should
`complete_protocol_step` also accept a step *number* spoken by the user (“done
with step three”)? (Plan assumes yes: accept id or 1-based position.)
2. **`nearest_expiry`** — is an expiry extra field available in your resources, or is
`fifo` + `specific` enough for v1? (Plan: ship `fifo`/`specific` first.)
3. **Journal location on phone/mobile** — default `~/.labvoice/journal.sqlite` okay,
or should the SQLite file live next to the config for easy backup?
4. **MCP SDK line** — plan targets the current stable `mcp` SDK (v2 line). Pin major
version at build time.
5. **Template search backend** — ship lexical ranking first and add `fastembed`
embeddings only if ranking disappoints, or embed from day one? (Plan: lexical
first, same interface for both.)
6. **REST auth** — one shared bearer token per deployment enough, or per-device
tokens for app installs?
+41
View File
@@ -0,0 +1,41 @@
[project]
name = "labvoice"
version = "0.0.1"
description = "Voice-driven eLabFTW assistant: MCP server + read-only REST fast path"
requires-python = ">=3.12"
dependencies = [
"mcp>=1.0",
"pydantic>=2.7",
"pydantic-settings>=2.2",
"aiosqlite>=0.20",
"fastapi>=0.111",
"uvicorn>=0.30",
]
[project.optional-dependencies]
embeddings = ["fastembed>=0.3"]
[dependency-groups]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.24",
# deps of the vendored elabapi_python client, imported (not installed) by tests
"six>=1.16",
"urllib3>=2.0",
"certifi>=2024.0",
]
[project.scripts]
labvoice = "mcp_server.__main__:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/mcp_server"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
+3
View File
@@ -0,0 +1,3 @@
"""LabVoice MCP server: aggregate eLabFTW tools for voice-driven wetlab use."""
__version__ = "0.0.1"
+19
View File
@@ -0,0 +1,19 @@
"""Entry point: stdio (default) or streamable-http."""
import argparse
def main(argv: list[str] | None = None) -> None:
"""Console script entry point; parses --transport and hands off to server.run."""
parser = argparse.ArgumentParser(prog="labvoice")
parser.add_argument(
"--transport",
choices=("stdio", "streamable-http"),
default="stdio",
)
args = parser.parse_args(argv)
raise NotImplementedError
if __name__ == "__main__":
main()
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
"""Container selection and unit whitelist/conversion."""
from mcp_server.models import AllocationPolicy, ContainerAllocation, ContainerStock
DEFAULT_UNIT_WHITELIST: tuple[str, ...] = ("μL", "mL", "L", "μg", "mg", "g", "kg", "ea")
def are_compatible(unit_a: str, unit_b: str) -> bool:
"""True when both units share a dimension and a tested conversion exists."""
raise NotImplementedError
def convert(quantity: float, from_unit: str, to_unit: str) -> float:
"""Convert within the same dimension across tested pairs only."""
raise NotImplementedError
def choose_containers(
containers: list[ContainerStock],
required: float,
unit: str,
policy: AllocationPolicy,
*,
expiry_field: str | None = None,
) -> list[ContainerAllocation]:
"""Apply the allocation policy (fifo/nearest_expiry/specific), splitting across containers."""
raise NotImplementedError
+21
View File
@@ -0,0 +1,21 @@
"""labvoice:v1 annotation parser + validator."""
from mcp_server.models import ProtocolAnnotation
ANNOTATION_PREFIX = "<!-- labvoice:v1"
ANNOTATION_SUFFIX = "-->"
def extract_annotation_block(step_body: str) -> str | None:
"""Return the raw last labvoice:v1 comment block, or None when absent."""
raise NotImplementedError
def parse_annotation(step_body: str) -> ProtocolAnnotation | None:
"""Parse the last valid labvoice:v1 block; None when no annotation present."""
raise NotImplementedError
def validate_annotation(step_body: str) -> list[str]:
"""Return schema violations for the annotation in the step body."""
raise NotImplementedError
+35
View File
@@ -0,0 +1,35 @@
"""Env-driven settings (pydantic-settings)."""
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
elabftw_url: str = ""
elabftw_api_key: str = ""
elabftw_timeout: float = 10.0
elabftw_retries: int = 2
labvoice_db_path: Path = Path("~/.labvoice/journal.sqlite")
labvoice_rest_token: str | None = None
labvoice_template_desc_limit: int = 200
labvoice_embed_model: str | None = None
labvoice_unit_whitelist: str = "μL,mL,L,μg,mg,g,kg,ea"
labvoice_expiry_field: str | None = None
@property
def unit_whitelist(self) -> tuple[str, ...]:
return tuple(u.strip() for u in self.labvoice_unit_whitelist.split(",") if u.strip())
@property
def api_base_url(self) -> str:
return f"{self.elabftw_url.rstrip('/')}/api/v2"
@lru_cache
def get_settings() -> Settings:
return Settings()
+98
View File
@@ -0,0 +1,98 @@
"""Async adapter over the vendored elabapi_python client (sync urllib3, run via asyncio.to_thread)."""
from typing import Literal
from mcp_server import schemas
from mcp_server.config import Settings
EntityType = Literal["experiments", "items"]
class ElabftwClient:
def __init__(self, settings: Settings) -> None:
self.settings = settings
async def get_info(self) -> schemas.ElabftwInfo:
"""GET /info — auth + reachability health check."""
raise NotImplementedError
async def search_experiments(
self,
*,
q: str | None = None,
tags: list[str] | None = None,
limit: int = 15,
offset: int = 0,
) -> list[schemas.ElabftwExperiment]:
"""GET /experiments — search by query/tags with pagination."""
raise NotImplementedError
async def get_experiment(self, experiment_id: int) -> schemas.ElabftwExperiment:
"""GET /experiments/{id}."""
raise NotImplementedError
async def create_experiment(
self, template_id: int, title: str | None = None
) -> schemas.ElabftwExperiment:
"""POST /experiments — create an experiment from a template."""
raise NotImplementedError
async def get_steps(
self, entity_type: EntityType, entity_id: int
) -> list[schemas.ElabftwStep]:
"""GET /{entity_type}/{id}/steps."""
raise NotImplementedError
async def finish_step(
self, entity_type: EntityType, entity_id: int, step_id: int
) -> schemas.ElabftwStep:
"""PATCH /{entity_type}/{id}/steps/{subid} {"action": "finish"}."""
raise NotImplementedError
async def list_comments(
self, entity_type: EntityType, entity_id: int
) -> list[schemas.ElabftwComment]:
"""GET /{entity_type}/{id}/comments."""
raise NotImplementedError
async def post_comment(
self, entity_type: EntityType, entity_id: int, body: str
) -> schemas.ElabftwComment:
"""POST /{entity_type}/{id}/comments."""
raise NotImplementedError
async def search_items(
self, *, q: str | None = None, limit: int = 15, offset: int = 0
) -> list[schemas.ElabftwItem]:
"""GET /items — scan/search resources."""
raise NotImplementedError
async def get_item(self, item_id: int) -> schemas.ElabftwItem:
"""GET /items/{id}."""
raise NotImplementedError
async def get_containers(
self, entity_type: EntityType, entity_id: int
) -> list[schemas.ContainerLink]:
"""GET /{entity_type}/{id}/containers."""
raise NotImplementedError
async def set_container_quantity(
self, entity_type: EntityType, entity_id: int, subitem_id: int, qty_stored: float
) -> None:
"""PATCH /{entity_type}/{id}/containers/{subid} {"qty_stored": ...}."""
raise NotImplementedError
async def list_storage_units(self) -> list[schemas.StorageUnit]:
"""GET /storage_units?hierarchy=true — location names for readback."""
raise NotImplementedError
async def list_templates(
self, *, q: str | None = None, limit: int = 25, offset: int = 0
) -> list[schemas.ElabftwTemplate]:
"""GET /experiments_templates — list/search templates."""
raise NotImplementedError
async def get_template(self, template_id: int) -> schemas.ElabftwTemplate:
"""GET /experiments_templates/{id}."""
raise NotImplementedError
+60
View File
@@ -0,0 +1,60 @@
"""Typed errors mapped to voice-friendly messages."""
from enum import StrEnum
class ErrorKind(StrEnum):
AUTH = "auth_error"
PERMISSION = "permission_error"
NOT_FOUND = "not_found"
API = "api_error"
ANNOTATION = "annotation_error"
CLARIFICATION = "clarification"
INSUFFICIENT_STOCK = "insufficient_stock"
class LabVoiceError(Exception):
kind: ErrorKind = ErrorKind.API
def __init__(self, message: str, *, detail: str | None = None) -> None:
super().__init__(message)
self.message = message
self.detail = detail
class AuthError(LabVoiceError):
kind = ErrorKind.AUTH
class PermissionDeniedError(LabVoiceError):
kind = ErrorKind.PERMISSION
class NotFoundError(LabVoiceError):
kind = ErrorKind.NOT_FOUND
class ApiError(LabVoiceError):
kind = ErrorKind.API
class AnnotationError(LabVoiceError):
kind = ErrorKind.ANNOTATION
class ClarificationNeededError(LabVoiceError):
kind = ErrorKind.CLARIFICATION
def __init__(
self,
message: str,
*,
options: list[str] | None = None,
detail: str | None = None,
) -> None:
super().__init__(message, detail=detail)
self.options = options or []
class InsufficientStockError(LabVoiceError):
kind = ErrorKind.INSUFFICIENT_STOCK
+81
View File
@@ -0,0 +1,81 @@
"""SQLite saga journal: idempotency, compensation, audit log."""
from enum import StrEnum
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field
class OperationStatus(StrEnum):
PLANNED = "planned"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
COMPENSATED = "compensated"
PARTIAL_FAILURE = "partial_failure"
class ActionStatus(StrEnum):
PLANNED = "planned"
DONE = "done"
FAILED = "failed"
COMPENSATED = "compensated"
class ActionRecord(BaseModel):
id: int
operation_id: str
action: str
status: ActionStatus = ActionStatus.PLANNED
payload: dict[str, Any] = Field(default_factory=dict)
response: dict[str, Any] | None = None
class OperationRecord(BaseModel):
operation_id: str
kind: str
status: OperationStatus = OperationStatus.PLANNED
actions: list[ActionRecord] = Field(default_factory=list)
class SagaJournal:
def __init__(self, db_path: Path) -> None:
self.db_path = db_path
async def connect(self) -> None:
"""Open/initialize the SQLite journal."""
raise NotImplementedError
async def close(self) -> None:
raise NotImplementedError
async def begin_operation(
self, kind: str, operation_id: str | None = None
) -> OperationRecord:
"""Start a journaled operation; an existing completed operation_id means idempotent replay."""
raise NotImplementedError
async def record_action(
self, operation_id: str, action: str, payload: dict[str, Any]
) -> ActionRecord:
raise NotImplementedError
async def mark_action(
self,
operation_id: str,
action_id: int,
status: ActionStatus,
response: dict[str, Any] | None = None,
) -> ActionRecord:
raise NotImplementedError
async def finish_operation(self, operation_id: str, status: OperationStatus) -> None:
raise NotImplementedError
async def get_operation(self, operation_id: str) -> OperationRecord:
raise NotImplementedError
async def has_completed(self, operation_id: str) -> bool:
"""True when this operation_id already ran to completion (idempotency check)."""
raise NotImplementedError
+179
View File
@@ -0,0 +1,179 @@
"""Tool input/output schemas and compact result types."""
from enum import StrEnum
from typing import Literal
from pydantic import BaseModel, Field
class AllocationPolicy(StrEnum):
FIFO = "fifo"
NEAREST_EXPIRY = "nearest_expiry"
SPECIFIC = "specific"
class Consumable(BaseModel):
resource_key: str
quantity: float | None = None
unit: str
allocation: AllocationPolicy = AllocationPolicy.FIFO
container_id: int | None = None
optional: bool = False
class ProtocolAnnotation(BaseModel):
consumables: list[Consumable] = Field(default_factory=list)
prompt_quantity: bool = False
class StepInfo(BaseModel):
id: int
position: int | None = None
body: str
finished: bool = False
class ContainerStock(BaseModel):
container_id: int
amount: str
available: float
unit: str
class NextStep(BaseModel):
experiment_id: int
step: StepInfo
consumables: list[Consumable] = Field(default_factory=list)
stock: list[ContainerStock] = Field(default_factory=list)
class ItemSummary(BaseModel):
id: int
title: str
resource_key: str | None = None
class CommentInfo(BaseModel):
id: int
body: str
created_at: str | None = None
class ExperimentSummary(BaseModel):
id: int
title: str
status: str | None = None
class ExperimentContext(BaseModel):
experiment_id: int
title: str
status: str | None = None
unfinished_steps: list[StepInfo] = Field(default_factory=list)
linked_resources: list[ItemSummary] = Field(default_factory=list)
recent_comments: list[CommentInfo] = Field(default_factory=list)
class AppState(BaseModel):
experiment_id: int
title: str
next_step: NextStep | None = None
unfinished_steps: list[int] = Field(default_factory=list)
class HandoffContext(BaseModel):
"""Minimal id-only context the app forwards to the edge LLM."""
experiment_id: int
user_id: int
step_id: int | None = None
device_id: str | None = None
location_id: str | None = None
class TemplateSummary(BaseModel):
id: int
title: str
short_description: str
tags: list[str] = Field(default_factory=list)
class CreateExperimentResult(BaseModel):
ok: bool = True
experiment_id: int
title: str
first_step: StepInfo | None = None
class ConsumedRecord(BaseModel):
resource_key: str
container_id: int
amount: str
remaining: str
class CompleteStepResult(BaseModel):
ok: bool = True
status: Literal["completed", "protocol_complete", "reverted", "partial_failure"] = "completed"
experiment_id: int
step: StepInfo
consumed: list[ConsumedRecord] = Field(default_factory=list)
comment_id: int | None = None
next_step: StepInfo | None = None
class ObservationResult(BaseModel):
ok: bool = True
experiment_id: int
comment_id: int | None = None
step: StepInfo | None = None
class InventoryResult(BaseModel):
ok: bool = True
container_id: int
resource_key: str | None = None
amount: str
remaining: str
class ContainerAllocation(BaseModel):
container_id: int
take: float
unit: str
remaining: str
class MappingEntry(BaseModel):
resource_key: str
item_id: int
title: str | None = None
unit: str | None = None
class MappingProposal(BaseModel):
resource_key: str
item_id: int
title: str | None = None
reason: str | None = None
score: float | None = None
class ScanResult(BaseModel):
containers_seen: int = 0
markers_found: int = 0
proposals: list[MappingProposal] = Field(default_factory=list)
class ValidationIssue(BaseModel):
severity: Literal["error", "warning"]
message: str
step_id: int | None = None
resource_key: str | None = None
class ValidationReport(BaseModel):
entity_type: str
entity_id: int
ok: bool
issues: list[ValidationIssue] = Field(default_factory=list)
+35
View File
@@ -0,0 +1,35 @@
"""resource_key → item id resolution (mapping store, markers, setup-time matchers)."""
from typing import Any
from mcp_server.config import Settings
from mcp_server.models import MappingEntry, MappingProposal
class ResourceResolver:
def __init__(self, settings: Settings) -> None:
self.settings = settings
async def resolve(self, resource_key: str) -> int:
"""Resolve a resource_key to an items id; ambiguity/absence raises ClarificationNeededError."""
raise NotImplementedError
async def find_candidates(self, resource_key: str) -> list[MappingProposal]:
"""Candidate resources for an unmapped key (markers, CAS, exact title)."""
raise NotImplementedError
async def upsert_mapping(self, resource_key: str, item_id: int) -> MappingEntry:
"""Bind resource_key → item_id (idempotent upsert into the local store)."""
raise NotImplementedError
async def list_mappings(self) -> list[MappingEntry]:
"""All bindings in the local mapping store."""
raise NotImplementedError
async def export_mapping(self) -> dict[str, Any]:
"""Serialize the mapping store to the portable JSON structure."""
raise NotImplementedError
async def import_mapping(self, data: dict[str, Any]) -> list[MappingProposal]:
"""Import a mapping file; propose matches, ambiguous ones need approval."""
raise NotImplementedError
+30
View File
@@ -0,0 +1,30 @@
"""Read-only REST mirror of the read tools for the mobile app (bearer-token protected)."""
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from fastapi import FastAPI
from mcp_server.config import Settings
ENDPOINTS: tuple[str, ...] = (
"GET /api/experiments?q=&limit=",
"GET /api/experiments/{id}/state",
"GET /api/experiments/{id}/next-step",
"GET /api/templates?q=&limit=",
)
def create_rest_app(settings: "Settings") -> "FastAPI":
"""Assemble the FastAPI app exposing the read endpoints; mutations never appear here."""
raise NotImplementedError
def verify_token(token: str) -> None:
"""Validate the bearer token against LABVOICE_REST_TOKEN."""
raise NotImplementedError
def error_payload(kind: str, message: str) -> dict[str, Any]:
"""Compact typed-error JSON body shared with the MCP results."""
raise NotImplementedError
+48
View File
@@ -0,0 +1,48 @@
"""Pydantic models for the eLabFTW entities we consume."""
from pydantic import BaseModel, ConfigDict
class ElabftwEntity(BaseModel):
model_config = ConfigDict(extra="allow")
class ElabftwInfo(ElabftwEntity):
pass
class ElabftwExperiment(ElabftwEntity):
id: int
title: str
class ElabftwItem(ElabftwEntity):
id: int
title: str
class ElabftwStep(ElabftwEntity):
id: int
body: str
finished: bool = False
ordering: int | None = None
class ElabftwComment(ElabftwEntity):
id: int
class ElabftwTemplate(ElabftwEntity):
id: int
title: str
class ContainerLink(ElabftwEntity):
id: int
qty_stored: float | None = None
unit: str | None = None
class StorageUnit(ElabftwEntity):
id: int
label: str | None = None
+25
View File
@@ -0,0 +1,25 @@
"""FastMCP assembly, lifespan and service wiring."""
from typing import TYPE_CHECKING, Any, Literal
from mcp_server.config import Settings, get_settings
if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP
Transport = Literal["stdio", "streamable-http"]
def build_services(settings: Settings) -> dict[str, Any]:
"""Wire adapter, resolver, journal, workflows and template service into one container."""
raise NotImplementedError
async def create_mcp_server(settings: Settings | None = None) -> "FastMCP":
"""Assemble the FastMCP server with all tools registered and a lifespan managing services."""
raise NotImplementedError
def run(transport: Transport = "stdio") -> None:
"""Run the server: stdio (default) or streamable-http (mounted with the REST app)."""
raise NotImplementedError
+27
View File
@@ -0,0 +1,27 @@
"""Experiment templates: description extraction, semantic ranking, creation flow."""
from mcp_server.config import Settings
from mcp_server.elabftw import ElabftwClient
from mcp_server.models import CreateExperimentResult, TemplateSummary
class TemplateService:
def __init__(self, client: ElabftwClient, settings: Settings) -> None:
self.client = client
self.settings = settings
async def list_templates(
self, query: str | None = None, limit: int = 10
) -> list[TemplateSummary]:
"""List/search templates (id, title, short description, tags); semantic ranking when queried."""
raise NotImplementedError
async def build_search_index(self) -> None:
"""(Re)build the template search corpus/vector cache in SQLite."""
raise NotImplementedError
async def create_experiment_from_template(
self, template_id: int, title: str | None = None
) -> CreateExperimentResult:
"""Create an experiment from a template; steps + annotations are copied by eLabFTW."""
raise NotImplementedError
+142
View File
@@ -0,0 +1,142 @@
"""MCP tool registration: one thin async function per tool, compact results."""
from typing import Any
from mcp_server.models import (
CompleteStepResult,
CreateExperimentResult,
ExperimentContext,
ExperimentSummary,
InventoryResult,
MappingEntry,
NextStep,
ObservationResult,
ScanResult,
TemplateSummary,
ValidationReport,
)
async def find_experiments(
query: str | None = None,
tags: list[str] | None = None,
limit: int = 15,
) -> list[ExperimentSummary]:
"""Search experiments by title/text/tag/custom id."""
raise NotImplementedError
async def get_experiment_context(experiment_id: int) -> ExperimentContext:
"""Metadata, unfinished steps, linked resources and recent comments, compact."""
raise NotImplementedError
async def get_next_protocol_step(experiment_id: int) -> NextStep | None:
"""Next unfinished step: text, parsed consumables, stock preview."""
raise NotImplementedError
async def list_experiment_templates(
query: str | None = None,
limit: int = 10,
) -> list[TemplateSummary]:
"""List/search experiment templates with a short description (semantic ranking)."""
raise NotImplementedError
async def validate_protocol_template(
entity_type: str,
entity_id: int,
) -> ValidationReport:
"""Check labvoice annotations + resource mappings of a template/experiment."""
raise NotImplementedError
async def complete_next_protocol_step(
experiment_id: int,
comment: str | None = None,
quantities: dict[str, float] | None = None,
) -> CompleteStepResult:
"""Allocate + decrement stock, finish the next step and post the comment in one call."""
raise NotImplementedError
async def complete_protocol_step(
experiment_id: int,
step_id: int,
comment: str | None = None,
quantities: dict[str, float] | None = None,
) -> CompleteStepResult:
"""Complete an explicit step (for “redo step 3” voice commands)."""
raise NotImplementedError
async def record_protocol_observation(
experiment_id: int,
comment: str,
step_id: int | None = None,
) -> ObservationResult:
"""Add a comment (+ optional step body edit) without completing anything."""
raise NotImplementedError
async def create_experiment_from_template(
template_id: int,
title: str | None = None,
) -> CreateExperimentResult:
"""Start a new experiment from a template (interactive template pick happens first)."""
raise NotImplementedError
async def adjust_inventory(
amount: float,
unit: str,
container_id: int | None = None,
resource_key: str | None = None,
note: str | None = None,
) -> InventoryResult:
"""Restock/correct a container (voice: “add 500 mL to …”)."""
raise NotImplementedError
async def scan_instance() -> ScanResult:
"""Inventory containers, detect resource markers, propose resource matches."""
raise NotImplementedError
async def map_resource(resource_key: str, item_id: int) -> MappingEntry:
"""Bind a semantic resource_key to an eLabFTW items id."""
raise NotImplementedError
async def export_mapping() -> dict[str, Any]:
"""Export the resource_key → item_id mapping store as portable JSON."""
raise NotImplementedError
async def import_mapping(data: dict[str, Any]) -> dict[str, Any]:
"""Import a mapping file; ambiguous matches are proposals requiring approval."""
raise NotImplementedError
TOOL_FUNCTIONS: tuple[Any, ...] = (
find_experiments,
get_experiment_context,
get_next_protocol_step,
list_experiment_templates,
validate_protocol_template,
complete_next_protocol_step,
complete_protocol_step,
record_protocol_observation,
create_experiment_from_template,
adjust_inventory,
scan_instance,
map_resource,
export_mapping,
import_mapping,
)
def register_tools(mcp: Any) -> None:
"""Register every tool function on the FastMCP instance."""
raise NotImplementedError
+1
View File
@@ -0,0 +1 @@
"""Aggregate workflow services (the point of the server)."""
+37
View File
@@ -0,0 +1,37 @@
"""adjust_inventory and stock reads."""
from mcp_server.config import Settings
from mcp_server.elabftw import ElabftwClient
from mcp_server.journal import SagaJournal
from mcp_server.models import ContainerStock, InventoryResult
from mcp_server.resolve import ResourceResolver
class InventoryWorkflow:
def __init__(
self,
client: ElabftwClient,
resolver: ResourceResolver,
journal: SagaJournal,
settings: Settings,
) -> None:
self.client = client
self.resolver = resolver
self.journal = journal
self.settings = settings
async def stock_for_resource(self, resource_key: str) -> list[ContainerStock]:
"""Containers holding the resolved resource with compatible units and current stock."""
raise NotImplementedError
async def adjust_inventory(
self,
*,
amount: float,
unit: str,
resource_key: str | None = None,
container_id: int | None = None,
note: str | None = None,
) -> InventoryResult:
"""Restock/correct a container (voice: “add 500 mL to …”); journaled."""
raise NotImplementedError
+63
View File
@@ -0,0 +1,63 @@
"""complete_next/complete/observation workflows (step completion saga)."""
from typing import Mapping
from mcp_server.config import Settings
from mcp_server.elabftw import ElabftwClient
from mcp_server.journal import SagaJournal
from mcp_server.models import (
CompleteStepResult,
NextStep,
ObservationResult,
)
from mcp_server.resolve import ResourceResolver
class ProtocolWorkflow:
def __init__(
self,
client: ElabftwClient,
resolver: ResourceResolver,
journal: SagaJournal,
settings: Settings,
) -> None:
self.client = client
self.resolver = resolver
self.journal = journal
self.settings = settings
async def get_next_step(self, experiment_id: int) -> NextStep | None:
"""Next unfinished step: text, parsed consumables, stock preview; None when protocol complete."""
raise NotImplementedError
async def complete_next_step(
self,
experiment_id: int,
*,
comment: str | None = None,
quantities: Mapping[str, float] | None = None,
) -> CompleteStepResult:
"""Headline workflow: allocate + decrement stock, finish step, post comment, journaled saga."""
raise NotImplementedError
async def complete_step(
self,
experiment_id: int,
step_id: int,
*,
comment: str | None = None,
quantities: Mapping[str, float] | None = None,
) -> CompleteStepResult:
"""Same saga with an explicit step id (for “redo step 3” voice commands)."""
raise NotImplementedError
async def record_observation(
self,
experiment_id: int,
comment: str,
*,
step_id: int | None = None,
body: str | None = None,
) -> ObservationResult:
"""Add a comment (+ optional step body edit) without completing anything."""
raise NotImplementedError
+32
View File
@@ -0,0 +1,32 @@
"""Instance onboarding: scan, mapping, template validation."""
from mcp_server.config import Settings
from mcp_server.elabftw import ElabftwClient
from mcp_server.models import MappingEntry, ScanResult, ValidationReport
from mcp_server.resolve import ResourceResolver
class SetupWorkflow:
def __init__(
self,
client: ElabftwClient,
resolver: ResourceResolver,
settings: Settings,
) -> None:
self.client = client
self.resolver = resolver
self.settings = settings
async def scan_instance(self) -> ScanResult:
"""Walk /items, inventory containers, detect markers, propose resource matches."""
raise NotImplementedError
async def map_resource(self, resource_key: str, item_id: int) -> MappingEntry:
"""Bind resource_key → item_id (idempotent, upsert into the mapping store)."""
raise NotImplementedError
async def validate_protocol_template(
self, entity_type: str, entity_id: int
) -> ValidationReport:
"""Check annotations + mappings of a template/experiment; blocking issues are errors."""
raise NotImplementedError
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+164
View File
@@ -0,0 +1,164 @@
"""Shared fixtures for the LabVoice MCP server test suite.
Conventions pinned by this suite (adjust here if the implementation diverges):
1. Workflows and services receive their collaborators via constructor
arguments (``client``, ``resolver``, ``journal``, ``settings``) exactly as
declared in the scaffolds — tests inject ``FakeElabftwClient`` /
``StubResolver`` and a real ``SagaJournal`` against a temp SQLite file.
2. ``SagaJournal`` has an explicit ``connect()``/``close()`` lifecycle.
3. The REST app and MCP tools resolve their read services through
``mcp_server.server.build_services(settings)`` (the wiring container from
the scaffold); tests monkeypatch that factory. The service container is a
dict with the keys implied by the scaffold's build_services comment:
``client``, ``resolver``, ``journal``, ``protocol``, ``inventory``,
``setup``, ``templates``.
4. MCP-level tests drive the real ``FastMCP`` server (from ``mcp`` 2.1.x)
in-process via ``mcp.client.Client``.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
# Make the vendored elabapi_python client importable for tests (it is vendored,
# not installed). Its deps (six, urllib3, certifi) come from the dev group.
_VENDOR = Path(__file__).resolve().parent.parent / "vendor" / "elabapi-python"
if str(_VENDOR) not in sys.path:
sys.path.insert(0, str(_VENDOR))
from mcp_server.config import Settings
from mcp_server.journal import SagaJournal
from mcp_server.resolve import ResourceResolver
from mcp_server.templates import TemplateService
from mcp_server.workflows.inventory import InventoryWorkflow
from mcp_server.workflows.protocol import ProtocolWorkflow
from mcp_server.workflows.setup import SetupWorkflow
from fakes import (
FakeElabftwClient,
StubResolver,
make_experiment,
make_item,
)
@pytest.fixture
def settings(tmp_path, monkeypatch) -> Settings:
"""Isolated settings: temp DB, no env/.env interference, distinctive API key."""
for var in (
"ELABFTW_URL",
"ELABFTW_API_KEY",
"LABVOICE_DB_PATH",
"LABVOICE_REST_TOKEN",
"LABVOICE_TEMPLATE_DESC_LIMIT",
"LABVOICE_EMBED_MODEL",
"LABVOICE_UNIT_WHITELIST",
"LABVOICE_EXPIRY_FIELD",
):
monkeypatch.delenv(var, raising=False)
return Settings(
_env_file=None,
elabftw_url="https://eln.example.org",
elabftw_api_key="labvoice-secret-key-do-not-leak",
elabftw_timeout=5.0,
elabftw_retries=0,
labvoice_db_path=tmp_path / "journal.sqlite",
)
@pytest.fixture
def client() -> FakeElabftwClient:
"""Standard scenario: experiment 123 (steps 9, 10) and ethanol item 12."""
return FakeElabftwClient(experiments=[make_experiment()], items=[make_item()])
@pytest.fixture
def resolver_stub() -> StubResolver:
"""ethanol_absolute → item 12; other keys raise clarification."""
return StubResolver(mappings={"ethanol_absolute": 12})
@pytest.fixture
async def journal(settings) -> SagaJournal:
journal = SagaJournal(settings.labvoice_db_path)
await journal.connect()
yield journal
await journal.close()
@pytest.fixture
def protocol(client, resolver_stub, journal, settings) -> ProtocolWorkflow:
return ProtocolWorkflow(
client=client, resolver=resolver_stub, journal=journal, settings=settings
)
@pytest.fixture
def inventory(client, resolver_stub, journal, settings) -> InventoryWorkflow:
return InventoryWorkflow(
client=client, resolver=resolver_stub, journal=journal, settings=settings
)
@pytest.fixture
def setup(client, resolver_stub, settings) -> SetupWorkflow:
return SetupWorkflow(client=client, resolver=resolver_stub, settings=settings)
@pytest.fixture
def templates_service(client, settings) -> TemplateService:
return TemplateService(client=client, settings=settings)
@pytest.fixture
async def real_resolver(settings):
"""Real ResourceResolver over a temp DB — resolver/mapping-store tests only."""
resolver = ResourceResolver(settings)
if hasattr(resolver, "connect"):
await resolver.connect()
yield resolver
if hasattr(resolver, "close"):
await resolver.close()
@pytest.fixture
def service_container(client, resolver_stub, journal, settings, templates_service) -> dict:
"""Fake ``build_services`` container (keys per scaffold comment)."""
return {
"client": client,
"resolver": resolver_stub,
"journal": journal,
"protocol": ProtocolWorkflow(
client=client, resolver=resolver_stub, journal=journal, settings=settings
),
"inventory": InventoryWorkflow(
client=client, resolver=resolver_stub, journal=journal, settings=settings
),
"setup": SetupWorkflow(client=client, resolver=resolver_stub, settings=settings),
"templates": templates_service,
}
@pytest.fixture
def patch_build_services(monkeypatch, service_container):
"""Intercept ``build_services`` wherever a transport layer imports it."""
def _patch():
import mcp_server.rest as rest_module
import mcp_server.server as server_module
import mcp_server.tools as tools_module
for module in (server_module, rest_module, tools_module):
monkeypatch.setattr(
module,
"build_services",
lambda _settings, _container=service_container: _container,
raising=False,
)
return service_container
return _patch
+505
View File
@@ -0,0 +1,505 @@
"""Test doubles shared across the suite.
Three fakes live here, each at a different seam:
* :class:`FakeElabftwClient` duck-types the ``ElabftwClient`` adapter interface.
It is a stateful in-memory eLabFTW instance used by workflow/service tests,
so saga behaviour can be asserted against real state changes.
* :class:`StubResolver` duck-types ``ResourceResolver`` with a plain dict of
mappings, keeping workflow tests independent of the SQLite mapping store.
* :func:`stub_elabapi` installs fake ``elabapi_python`` API classes into the
``mcp_server.elabftw`` module namespace. Convention under test: the adapter
resolves vendored API classes as module globals of ``mcp_server.elabftw``
(``from elabapi_python import ExperimentsApi`` ...) and instantiates them
with an api_client. If the implementation wires this differently, only the
injection point in these stubs needs to move.
Scenario builders (``make_experiment``, ``make_item`` ...) produce the standard
fixtures referenced throughout the tests.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from mcp_server.errors import ClarificationNeededError, NotFoundError, PermissionDeniedError
from mcp_server.models import (
AllocationPolicy,
MappingEntry,
MappingProposal,
)
from mcp_server.schemas import (
ContainerLink,
ElabftwComment,
ElabftwExperiment,
ElabftwInfo,
ElabftwItem,
ElabftwStep,
ElabftwTemplate,
StorageUnit,
)
# ---------------------------------------------------------------------------
# Annotation snippets (the labvoice:v1 format from planning §4)
# ---------------------------------------------------------------------------
ANNOTATION_ETHANOL_FIFO = (
"<!-- labvoice:v1\n"
'{"consumables": [{"resource_key": "ethanol_absolute", "quantity": 2.0,'
' "unit": "mL", "allocation": "fifo"}]}\n'
"-->"
)
# ---------------------------------------------------------------------------
# Fake eLabFTW state (adapter-level)
# ---------------------------------------------------------------------------
@dataclass
class FakeStep:
id: int
body: str
finished: bool = False
ordering: int | None = None
@dataclass
class FakeComment:
id: int
body: str
created_at: str | None = None
@dataclass
class FakeContainer:
sub_id: int
qty_stored: float
unit: str
@dataclass
class FakeItem:
id: int
title: str
body: str = ""
extra_fields: dict = field(default_factory=dict)
containers: list[FakeContainer] = field(default_factory=list)
@dataclass
class FakeExperiment:
id: int
title: str
status: str = "running"
steps: list[FakeStep] = field(default_factory=list)
comments: list[FakeComment] = field(default_factory=list)
linked_items: list[int] = field(default_factory=list)
@dataclass
class FakeTemplate:
id: int
title: str
description: str = ""
tags: list[str] = field(default_factory=list)
steps: list[FakeStep] = field(default_factory=list)
def make_template(**overrides) -> FakeTemplate:
"""Standard template: two plain steps, description with two paragraphs."""
tpl = FakeTemplate(
id=7,
title="PCR cleanup",
description="Purify an amplicon after PCR.\n\nLonger protocol notes follow here.",
tags=["dna", "cleanup"],
steps=[
FakeStep(id=41, body="Add binding buffer", ordering=1),
FakeStep(id=42, body="Elute", ordering=2),
],
)
for key, value in overrides.items():
setattr(tpl, key, value)
return tpl
def make_experiment(**overrides) -> FakeExperiment:
"""Standard experiment: step 9 (annotated) and step 10 (plain), both unfinished."""
exp = FakeExperiment(
id=123,
title="Plasmid prep",
steps=[
FakeStep(
id=9,
body=f"Add ethanol to the tube\n{ANNOTATION_ETHANOL_FIFO}",
ordering=1,
),
FakeStep(id=10, body="Incubate 30 min", ordering=2),
],
linked_items=[12],
)
for key, value in overrides.items():
setattr(exp, key, value)
return exp
def make_item(**overrides) -> FakeItem:
"""Standard resource: ethanol with the marker comment and two containers."""
item = FakeItem(
id=12,
title="Absolute Ethanol",
body="<p>Absolutely ethanol.</p>\n<!-- labvoice:resource-key=ethanol_absolute -->",
containers=[
FakeContainer(sub_id=31, qty_stored=50.0, unit="mL"),
FakeContainer(sub_id=32, qty_stored=10.0, unit="mL"),
],
)
for key, value in overrides.items():
setattr(item, key, value)
return item
class FakeElabftwClient:
"""In-memory stand-in for the async eLabFTW adapter.
Mirrors the ``ElabftwClient`` method surface. All mutations are recorded
in ``self.calls`` as ``(method_name, kwargs)`` tuples so tests can assert
exactly what changed (and that nothing changed when a workflow must abort).
``read_only=True`` makes every mutating method raise
``PermissionDeniedError`` like a canwrite=false API key would.
"""
def __init__(
self,
*,
experiments: list[FakeExperiment] | None = None,
items: list[FakeItem] | None = None,
templates: list[FakeTemplate] | None = None,
storage_units: list[StorageUnit] | None = None,
read_only: bool = False,
) -> None:
self.experiments = {e.id: e for e in (experiments or [])}
self.items = {i.id: i for i in (items or [])}
self.templates = {t.id: t for t in (templates or [])}
self.storage_units = storage_units or [
StorageUnit(id=1, label="Bench fridge / Shelf 2")
]
self.read_only = read_only
self.calls: list[tuple[str, dict]] = []
self._next_comment_id = 500
self._next_experiment_id = 1000
# -- call recording ----------------------------------------------------
def _record(self, method: str, **kwargs) -> None:
self.calls.append((method, kwargs))
def calls_of(self, method: str) -> list[dict]:
return [kwargs for name, kwargs in self.calls if name == method]
def _deny_if_read_only(self) -> None:
if self.read_only:
raise PermissionDeniedError(
"The API key cannot modify eLabFTW; ask an admin for a writable key."
)
# -- reads ---------------------------------------------------------------
async def get_info(self) -> ElabftwInfo:
self._record("get_info")
return ElabftwInfo(elabftw_version="5.0.0", api_version="2")
async def search_experiments(self, *, q=None, tags=None, limit=15, offset=0):
self._record("search_experiments", q=q, tags=tags, limit=limit, offset=offset)
hits = list(self.experiments.values())
if q:
hits = [e for e in hits if q.lower() in e.title.lower()]
if tags:
hits = [e for e in hits if any(t in tags for t in getattr(e, "tags", []))]
return [
ElabftwExperiment(id=e.id, title=e.title, status=e.status)
for e in hits[offset : offset + limit]
]
async def get_experiment(self, experiment_id: int) -> ElabftwExperiment:
self._record("get_experiment", experiment_id=experiment_id)
exp = self._entity("experiments", experiment_id)
return ElabftwExperiment(id=exp.id, title=exp.title, status=exp.status)
async def create_experiment(self, template_id: int, title=None):
self._record("create_experiment", template_id=template_id, title=title)
self._deny_if_read_only()
if template_id not in self.templates:
raise NotFoundError(f"Template {template_id} not found.")
tpl = self.templates[template_id]
new_id = self._next_experiment_id
self._next_experiment_id += 1
exp = FakeExperiment(
id=new_id,
title=title or tpl.title,
steps=[
FakeStep(id=s.id, body=s.body, ordering=s.ordering) for s in tpl.steps
],
)
self.experiments[new_id] = exp
return ElabftwExperiment(id=exp.id, title=exp.title, status=exp.status)
async def get_steps(self, entity_type, entity_id):
self._record("get_steps", entity_type=entity_type, entity_id=entity_id)
steps = self._entity(entity_type, entity_id).steps
return [
ElabftwStep(id=s.id, body=s.body, finished=s.finished, ordering=s.ordering)
for s in steps
]
async def finish_step(self, entity_type, entity_id, step_id):
self._record(
"finish_step", entity_type=entity_type, entity_id=entity_id, step_id=step_id
)
self._deny_if_read_only()
for step in self._entity(entity_type, entity_id).steps:
if step.id == step_id:
if step.finished:
return ElabftwStep(
id=step.id, body=step.body, finished=True, ordering=step.ordering
)
step.finished = True
return ElabftwStep(
id=step.id, body=step.body, finished=True, ordering=step.ordering
)
raise NotFoundError(f"Step {step_id} not found on {entity_type} {entity_id}.")
async def list_comments(self, entity_type, entity_id):
self._record("list_comments", entity_type=entity_type, entity_id=entity_id)
return [
ElabftwComment(id=c.id, body=c.body, created_at=c.created_at)
for c in self._entity(entity_type, entity_id).comments
]
async def post_comment(self, entity_type, entity_id, body):
self._record(
"post_comment", entity_type=entity_type, entity_id=entity_id, body=body
)
self._deny_if_read_only()
comment = FakeComment(id=self._next_comment_id, body=body)
self._next_comment_id += 1
self._entity(entity_type, entity_id).comments.append(comment)
return ElabftwComment(id=comment.id, body=comment.body, created_at=None)
async def search_items(self, *, q=None, limit=15, offset=0):
self._record("search_items", q=q, limit=limit, offset=offset)
hits = list(self.items.values())
if q:
hits = [i for i in hits if q.lower() in i.title.lower()]
return [self._item_model(i) for i in hits[offset : offset + limit]]
async def get_item(self, item_id: int) -> ElabftwItem:
self._record("get_item", item_id=item_id)
return self._item_model(self._entity("items", item_id))
async def get_containers(self, entity_type, entity_id):
self._record("get_containers", entity_type=entity_type, entity_id=entity_id)
item = self._entity("items", entity_id)
return [
ContainerLink(id=c.sub_id, qty_stored=c.qty_stored, unit=c.unit)
for c in item.containers
]
async def set_container_quantity(self, entity_type, entity_id, subitem_id, qty_stored):
self._record(
"set_container_quantity",
entity_type=entity_type,
entity_id=entity_id,
subitem_id=subitem_id,
qty_stored=qty_stored,
)
self._deny_if_read_only()
for container in self._entity("items", entity_id).containers:
if container.sub_id == subitem_id:
container.qty_stored = qty_stored
return
raise NotFoundError(f"Container {subitem_id} not found on item {entity_id}.")
async def list_storage_units(self):
self._record("list_storage_units")
return list(self.storage_units)
async def list_templates(self, *, q=None, limit=25, offset=0):
self._record("list_templates", q=q, limit=limit, offset=offset)
hits = list(self.templates.values())
if q:
hits = [t for t in hits if q.lower() in t.title.lower()]
return [
ElabftwTemplate(id=t.id, title=t.title, description=t.description, tags=t.tags)
for t in hits[offset : offset + limit]
]
async def get_template(self, template_id: int) -> ElabftwTemplate:
self._record("get_template", template_id=template_id)
if template_id not in self.templates:
raise NotFoundError(f"Template {template_id} not found.")
t = self.templates[template_id]
return ElabftwTemplate(id=t.id, title=t.title, description=t.description, tags=t.tags)
# -- helpers -------------------------------------------------------------
def _entity(self, entity_type, entity_id):
if entity_type == "experiments":
if entity_id not in self.experiments:
raise NotFoundError(f"Experiment {entity_id} not found.")
return self.experiments[entity_id]
if entity_id not in self.items:
raise NotFoundError(f"Item {entity_id} not found.")
return self.items[entity_id]
def _item_model(self, item: FakeItem) -> ElabftwItem:
return ElabftwItem(
id=item.id,
title=item.title,
body=item.body,
**{"extra_fields": item.extra_fields} if item.extra_fields else {},
)
# ---------------------------------------------------------------------------
# Stub resolver (resolver-level)
# ---------------------------------------------------------------------------
class StubResolver:
"""Dict-backed ``ResourceResolver`` stand-in for workflow tests."""
def __init__(
self,
mappings: dict[str, int] | None = None,
*,
fail_on: set[str] | None = None,
options_by_key: dict[str, list[str]] | None = None,
) -> None:
self.mappings = dict(mappings or {})
self.fail_on = fail_on or set()
self.options_by_key = options_by_key or {}
self.resolved: list[str] = []
async def resolve(self, resource_key: str) -> int:
self.resolved.append(resource_key)
if resource_key in self.fail_on or resource_key not in self.mappings:
raise ClarificationNeededError(
f"I don't know which resource '{resource_key}' refers to.",
options=self.options_by_key.get(resource_key, []),
)
return self.mappings[resource_key]
async def find_candidates(self, resource_key: str) -> list[MappingProposal]:
return [
MappingProposal(resource_key=resource_key, item_id=item_id)
for item_id in sorted(self.mappings.values())
]
async def upsert_mapping(self, resource_key: str, item_id: int) -> MappingEntry:
self.mappings[resource_key] = item_id
return MappingEntry(resource_key=resource_key, item_id=item_id)
async def list_mappings(self) -> list[MappingEntry]:
return [
MappingEntry(resource_key=k, item_id=v)
for k, v in sorted(self.mappings.items())
]
# ---------------------------------------------------------------------------
# elabapi_python stubs (client construction seam, adapter tests only)
# ---------------------------------------------------------------------------
class StubElabapi:
"""Fake vendored elabapi_python client hub.
Each generated ``*Api`` class is replaced by a thin proxy that forwards
every method call to :meth:`handle`, which consults routes registered by
the test. Unrouted calls fail loudly, so adapter tests only ever pass
against the endpoints they explicitly define.
"""
API_CLASSES = (
"InfoApi",
"InstanceApi",
"ExperimentsApi",
"ExperimentsTemplatesApi",
"StepsApi",
"CommentsApi",
"ItemsApi",
"ContainersApi",
"StorageUnitsApi",
"LinksToItemsApi",
"UnfinishedStepsApi",
"TodolistApi",
)
def __init__(self) -> None:
self.routes: dict[tuple[str, str], object] = {}
self.calls: list[tuple[str, str, dict]] = []
self.thread_ids: list[tuple[str, int]] = []
def route(self, api: str, method: str, handler) -> None:
self.routes[(api, method)] = handler
async def handle(self, api: str, method: str, args: tuple, kwargs: dict):
self.calls.append((api, method, kwargs))
self.thread_ids.append((f"{api}.{method}", __import__("threading").get_ident()))
if (api, method) not in self.routes and (api, "*") not in self.routes:
raise AssertionError(f"Unexpected elabapi call: {api}.{method}{args}{kwargs}")
handler = self.routes.get((api, method)) or self.routes[(api, "*")]
return handler(*args, **kwargs)
def make_stub_elabapi_cls(hub: StubElabapi, api_name: str):
class _ApiProxy:
def __init__(self, api_client=None, *args, **kwargs):
self.api_client = api_client
def __getattr__(self, method: str):
async def _call(*args, **kwargs):
return await hub.handle(api_name, method, args, kwargs)
_call.__name__ = method
return _call
_ApiProxy.__name__ = f"Stub{api_name}"
return _ApiProxy
def stub_elabapi(monkeypatch, hub: StubElabapi) -> StubElabapi:
"""Patch elabapi API classes (and Configuration/ApiClient) into mcp_server.elabftw."""
import elabapi_python.rest as elabapi_rest
import mcp_server.elabftw as elabftw_module
for api_name in hub.API_CLASSES:
monkeypatch.setattr(
elabftw_module,
api_name,
make_stub_elabapi_cls(hub, api_name),
raising=False,
)
class _StubConfiguration:
def __init__(self, host=None, api_key=None, username=None, password=None, **kw):
self.host = host
self.api_key = api_key
self.username = username
self.password = password
class _StubApiClient:
def __init__(self, configuration=None, **kw):
self.configuration = configuration
def set_default_header(self, name, value):
pass
monkeypatch.setattr(elabftw_module, "Configuration", _StubConfiguration, raising=False)
monkeypatch.setattr(elabftw_module, "ApiClient", _StubApiClient, raising=False)
monkeypatch.setattr(
elabftw_module, "ApiException", elabapi_rest.ApiException, raising=False
)
return hub
+176
View File
@@ -0,0 +1,176 @@
"""allocate.py — unit whitelist/conversion + allocation policies (plan §4, §7).
Tested conversion pairs per the plan: mL↔L and the mass chain μg↔mg↔g↔kg;
anything outside the whitelist or across dimensions must be a clarification,
never a guess.
"""
import pytest
from mcp_server.allocate import are_compatible, choose_containers, convert
from mcp_server.errors import ClarificationNeededError, InsufficientStockError
from mcp_server.models import AllocationPolicy, ContainerStock
def stock(cid: int, available: float, unit: str = "mL") -> ContainerStock:
return ContainerStock(container_id=cid, amount=f"{available} {unit}", available=available, unit=unit)
# --- unit compatibility -----------------------------------------------------
@pytest.mark.parametrize(
("a", "b"),
[("mL", "L"), ("L", "mL"), ("mg", "g"), ("g", "kg"), ("μg", "mg"), ("ea", "ea")],
)
def test_compatible_within_dimension(a, b):
assert are_compatible(a, b)
@pytest.mark.parametrize(
("a", "b"),
[("mL", "mg"), ("L", "ea"), ("ea", "mL"), ("g", "μL")],
)
def test_incompatible_across_dimensions(a, b):
assert not are_compatible(a, b)
@pytest.mark.parametrize("unit", ["fl-oz", "cups", "", "mmol"])
def test_unknown_units_are_never_compatible(unit):
assert not are_compatible(unit, "mL")
assert not are_compatible("mL", unit)
# --- conversion -------------------------------------------------------------
def test_volume_conversions():
assert convert(1.0, "L", "mL") == pytest.approx(1000.0)
assert convert(500.0, "mL", "L") == pytest.approx(0.5)
def test_mass_conversions():
assert convert(1.0, "g", "mg") == pytest.approx(1000.0)
assert convert(1.0, "kg", "g") == pytest.approx(1000.0)
assert convert(1000.0, "mg", "g") == pytest.approx(1.0)
assert convert(1000.0, "μg", "mg") == pytest.approx(1.0)
def test_count_is_identity():
assert convert(3.0, "ea", "ea") == 3.0
def test_cross_dimension_conversion_is_a_clarification():
with pytest.raises(ClarificationNeededError):
convert(1.0, "mL", "mg")
def test_unknown_unit_conversion_is_a_clarification():
with pytest.raises(ClarificationNeededError):
convert(1.0, "fl-oz", "mL")
# --- fifo allocation --------------------------------------------------------
def test_fifo_takes_from_lowest_container_id_with_stock():
allocations = choose_containers(
[stock(45, 5.0), stock(31, 50.0)], required=2.0, unit="mL", policy=AllocationPolicy.FIFO
)
assert len(allocations) == 1
assert allocations[0].container_id == 31
assert allocations[0].take == pytest.approx(2.0)
assert allocations[0].remaining == "48.0 mL"
def test_fifo_splits_across_containers_when_first_is_short():
allocations = choose_containers(
[stock(31, 1.5), stock(32, 10.0)], required=2.0, unit="mL", policy=AllocationPolicy.FIFO
)
assert [a.container_id for a in allocations] == [31, 32]
assert allocations[0].take == pytest.approx(1.5)
assert allocations[1].take == pytest.approx(0.5)
assert allocations[1].remaining == "9.5 mL"
def test_fifo_skips_empty_containers():
allocations = choose_containers(
[stock(31, 0.0), stock(32, 10.0)], required=2.0, unit="mL", policy=AllocationPolicy.FIFO
)
assert [a.container_id for a in allocations] == [32]
def test_fifo_exact_fit_leaves_container_empty():
allocations = choose_containers(
[stock(31, 2.0)], required=2.0, unit="mL", policy=AllocationPolicy.FIFO
)
assert allocations[0].remaining == "0.0 mL"
def test_insufficient_stock_raises_with_shortfall():
with pytest.raises(InsufficientStockError) as excinfo:
choose_containers(
[stock(31, 1.0), stock(32, 0.5)], required=2.0, unit="mL", policy=AllocationPolicy.FIFO
)
message = str(excinfo.value)
assert "0.5" in message # 1.5 available vs 2.0 required → 0.5 short
def test_insufficient_stock_with_no_containers_at_all():
with pytest.raises(InsufficientStockError):
choose_containers([], required=2.0, unit="mL", policy=AllocationPolicy.FIFO)
# --- unit conversion during allocation --------------------------------------
def test_allocation_converts_between_compatible_units():
"""Required 0.5 L against a container stocked in mL."""
allocations = choose_containers(
[stock(31, 1000.0, "mL")], required=0.5, unit="L", policy=AllocationPolicy.FIFO
)
assert allocations[0].take == pytest.approx(500.0)
assert allocations[0].remaining == "500.0 mL"
def test_allocation_rejects_incompatible_container_units():
containers = [stock(31, 10.0, "mg")]
with pytest.raises(ClarificationNeededError):
choose_containers(containers, required=2.0, unit="mL", policy=AllocationPolicy.FIFO)
# --- specific allocation ----------------------------------------------------
def test_specific_takes_only_from_the_listed_container():
"""The caller pre-filters to the hinted container (consumable.container_id)."""
allocations = choose_containers(
[stock(32, 10.0)], required=2.0, unit="mL", policy=AllocationPolicy.SPECIFIC
)
assert [a.container_id for a in allocations] == [32]
def test_specific_with_unavailable_container_is_a_clarification():
with pytest.raises(ClarificationNeededError):
choose_containers([], required=2.0, unit="mL", policy=AllocationPolicy.SPECIFIC)
# --- nearest expiry (deferred per plan §15.2, kept as executable spec) ------
@pytest.mark.xfail(reason="nearest_expiry deferred per plan §15.2; expiry_field config pending")
def test_nearest_expiry_picks_soonest_expiry_first():
soon = ContainerStock.model_construct(
container_id=44, amount="5 mL", available=5.0, unit="mL", expires="2026-01-01"
)
later = ContainerStock.model_construct(
container_id=31, amount="5 mL", available=5.0, unit="mL", expires="2027-01-01"
)
allocations = choose_containers(
[later, soon],
required=2.0,
unit="mL",
policy=AllocationPolicy.NEAREST_EXPIRY,
expiry_field="Expiry date",
)
assert allocations[0].container_id == 44
+159
View File
@@ -0,0 +1,159 @@
"""annotate.py — labvoice:v1 parser rules (plan §4, §13)."""
import json
import pytest
from mcp_server.annotate import extract_annotation_block, parse_annotation, validate_annotation
from mcp_server.errors import AnnotationError
from mcp_server.models import AllocationPolicy
def valid_block(consumables: list[dict], **extra) -> str:
payload = {"consumables": consumables, **extra}
return f"<!-- labvoice:v1\n{json.dumps(payload)}\n-->"
ETHANOL = {
"resource_key": "ethanol_absolute",
"quantity": 2.0,
"unit": "mL",
"allocation": "fifo",
}
# --- extraction -------------------------------------------------------------
def test_extract_returns_block_when_present():
body = f"Add ethanol to the tube\n{valid_block([ETHANOL])}"
block = extract_annotation_block(body)
assert block is not None
assert "labvoice:v1" in block
assert "ethanol_absolute" in block
def test_extract_returns_none_when_no_annotation():
assert extract_annotation_block("Just a normal step.") is None
assert extract_annotation_block("") is None
def test_extract_ignores_other_html_comments():
body = "Step text\n<!-- some editor note -->\nmore text"
assert extract_annotation_block(body) is None
def test_extract_takes_the_last_valid_block():
first = valid_block([{**ETHANOL, "quantity": 1.0}])
second = valid_block([{**ETHANOL, "quantity": 2.0}])
body = f"Step text\n{first}\n{second}"
block = extract_annotation_block(body)
assert json.loads(block.split("labvoice:v1")[1].rsplit("-->")[0])["consumables"][0][
"quantity"
] == 2.0
def test_extract_tolerates_multiline_and_whitespace():
body = "text\n<!-- labvoice:v1\n\n {\"consumables\": []} \n -->"
assert extract_annotation_block(body) is not None
# --- parsing ----------------------------------------------------------------
def test_parse_valid_annotation():
body = f"Add ethanol\n{valid_block([ETHANOL])}"
annotation = parse_annotation(body)
assert annotation is not None
assert len(annotation.consumables) == 1
consumable = annotation.consumables[0]
assert consumable.resource_key == "ethanol_absolute"
assert consumable.quantity == 2.0
assert consumable.unit == "mL"
assert consumable.allocation is AllocationPolicy.FIFO
assert annotation.prompt_quantity is False
def test_parse_returns_none_without_annotation():
assert parse_annotation("Plain step body.") is None
def test_parse_defaults_allocation_to_fifo():
body = f"x\n{valid_block([{k: ETHANOL[k] for k in ('resource_key', 'quantity', 'unit')}])}"
annotation = parse_annotation(body)
assert annotation.consumables[0].allocation is AllocationPolicy.FIFO
def test_parse_prompt_quantity_annotation():
body = f"x\n{valid_block([{'resource_key': 'buffer', 'quantity': None, 'unit': 'mL'}], prompt_quantity=True)}"
annotation = parse_annotation(body)
assert annotation.prompt_quantity is True
assert annotation.consumables[0].quantity is None
def test_parse_malformed_json_raises_annotation_error():
body = "text\n<!-- labvoice:v1\n{not json at all\n-->"
with pytest.raises(AnnotationError):
parse_annotation(body)
def test_parse_schema_violation_missing_required_field():
body = f"x\n{valid_block([{'resource_key': 'ethanol_absolute', 'unit': 'mL'}])}"
with pytest.raises(AnnotationError):
parse_annotation(body)
def test_parse_schema_violation_unknown_allocation():
body = f"x\n{valid_block([{**ETHANOL, 'allocation': 'magical'}])}"
with pytest.raises(AnnotationError):
parse_annotation(body)
def test_parse_schema_violation_negative_quantity():
body = f"x\n{valid_block([{**ETHANOL, 'quantity': -2.0}])}"
with pytest.raises(AnnotationError):
parse_annotation(body)
def test_parse_schema_violation_null_quantity_requires_prompt_quantity():
body = f"x\n{valid_block([{'resource_key': 'buffer', 'quantity': None, 'unit': 'mL'}])}"
with pytest.raises(AnnotationError):
parse_annotation(body)
def test_parse_schema_violation_container_id_outside_specific():
body = f"x\n{valid_block([{**ETHANOL, 'container_id': 31}])}"
with pytest.raises(AnnotationError):
parse_annotation(body)
def test_parse_allows_container_id_with_specific_allocation():
body = f"x\n{valid_block([{**ETHANOL, 'allocation': 'specific', 'container_id': 31}])}"
annotation = parse_annotation(body)
assert annotation.consumables[0].container_id == 31
# --- validation -------------------------------------------------------------
def test_validate_clean_annotation_returns_no_violations():
body = f"Add ethanol\n{valid_block([ETHANOL])}"
assert validate_annotation(body) == []
def test_validate_body_without_annotation_has_nothing_to_check():
assert validate_annotation("No annotations here.") == []
def test_validate_malformed_annotation_reports_violation():
body = "text\n<!-- labvoice:v1\n{broken\n-->"
issues = validate_annotation(body)
assert issues, "malformed annotation must be reported, never silently ignored"
assert any("json" in issue.lower() or "parse" in issue.lower() for issue in issues)
def test_validate_missing_required_field_is_reported():
body = f"x\n{valid_block([{'resource_key': 'ethanol_absolute'}])}"
issues = validate_annotation(body)
assert issues
assert any("unit" in issue.lower() or "quantity" in issue.lower() for issue in issues)
+50
View File
@@ -0,0 +1,50 @@
"""config.py — env-driven settings (plan §11)."""
from pathlib import Path
from mcp_server.config import Settings, get_settings
def test_defaults():
settings = Settings(_env_file=None)
assert settings.elabftw_timeout == 10.0
assert settings.elabftw_retries == 2
assert settings.labvoice_db_path == Path("~/.labvoice/journal.sqlite")
assert settings.labvoice_template_desc_limit == 200
assert settings.labvoice_rest_token is None
assert settings.labvoice_embed_model is None
def test_default_unit_whitelist_matches_plan():
settings = Settings(_env_file=None)
assert "μL" in settings.unit_whitelist
assert "mL" in settings.unit_whitelist
assert "ea" in settings.unit_whitelist
def test_unit_whitelist_parsing_ignores_blanks_and_spaces():
settings = Settings(_env_file=None, labvoice_unit_whitelist=" mL , L , , ea ")
assert settings.unit_whitelist == ("mL", "L", "ea")
def test_api_base_url_strips_trailing_slash():
settings = Settings(_env_file=None, elabftw_url="https://eln.example.org/")
assert settings.api_base_url == "https://eln.example.org/api/v2"
def test_env_overrides(monkeypatch):
monkeypatch.setenv("ELABFTW_URL", "https://eln2.example.org")
monkeypatch.setenv("ELABFTW_TIMEOUT", "3.5")
monkeypatch.setenv("LABVOICE_DB_PATH", "/tmp/other.sqlite")
settings = Settings(_env_file=None)
assert settings.elabftw_url == "https://eln2.example.org"
assert settings.elabftw_timeout == 3.5
assert settings.labvoice_db_path == Path("/tmp/other.sqlite")
def test_get_settings_is_cached(monkeypatch):
monkeypatch.setenv("ELABFTW_URL", "https://a.example.org")
first = get_settings()
monkeypatch.setenv("ELABFTW_URL", "https://b.example.org")
assert get_settings() is first
get_settings.cache_clear()
+138
View File
@@ -0,0 +1,138 @@
"""elabftw.py — async adapter over vendored elabapi_python (plan §3, §12).
Error normalization: ApiException status → typed error; network failures →
api_error; the API key must never leak into messages. Sync client calls run
off the event loop.
Convention under test: the adapter resolves vendored API classes as module
globals of ``mcp_server.elabftw`` and instantiates them with an api_client;
tests stub those globals (see tests/fakes.stub_elabapi). Routes use the
``"*"`` wildcard method so the suite doesn't pin the generated method names.
"""
import threading
import pytest
from elabapi_python.rest import ApiException
from fakes import StubElabapi, stub_elabapi
from mcp_server.errors import ApiError, AuthError, NotFoundError, PermissionDeniedError
from mcp_server.elabftw import ElabftwClient
@pytest.fixture
def hub() -> StubElabapi:
return StubElabapi()
@pytest.fixture
def adapter(settings, hub, monkeypatch) -> ElabftwClient:
stub_elabapi(monkeypatch, hub)
return ElabftwClient(settings)
INFO_PAYLOAD = {"elabftw_version": "5.0.0", "api": "2"}
EXPERIMENT_PAYLOAD = {"id": 123, "title": "Plasmid prep"}
async def test_get_info_returns_schema(adapter, hub):
hub.route("InfoApi", "*", lambda **kw: INFO_PAYLOAD)
info = await adapter.get_info()
assert info.elabftw_version == "5.0.0"
async def test_get_experiment_maps_payload(adapter, hub):
hub.route("ExperimentsApi", "*", lambda **kw: EXPERIMENT_PAYLOAD)
experiment = await adapter.get_experiment(123)
assert experiment.id == 123
assert experiment.title == "Plasmid prep"
@pytest.mark.parametrize(
("status", "expected"),
[(401, AuthError), (403, PermissionDeniedError), (404, NotFoundError), (500, ApiError)],
)
async def test_http_status_normalization(adapter, hub, status, expected):
def boom(**kw):
raise ApiException(status=status, reason="boom")
hub.route("ExperimentsApi", "*", boom)
with pytest.raises(expected):
await adapter.get_experiment(123)
async def test_network_failure_becomes_api_error(adapter, hub):
def boom(**kw):
raise OSError("connection refused")
hub.route("ExperimentsApi", "*", boom)
with pytest.raises(ApiError):
await adapter.get_experiment(123)
@pytest.mark.parametrize("status", [401, 403, 404, 500])
async def test_api_key_never_leaks_into_error_messages(settings, hub, monkeypatch, status):
stub_elabapi(monkeypatch, hub)
def boom(**kw):
raise ApiException(
status=status, reason="boom", body="key=labvoice-secret-key-do-not-leak"
)
hub.route("ExperimentsApi", "*", boom)
client = ElabftwClient(settings)
with pytest.raises(Exception) as excinfo:
await client.get_experiment(123)
assert "labvoice-secret-key-do-not-leak" not in str(excinfo.value)
assert "labvoice-secret-key-do-not-leak" not in repr(excinfo.value)
async def test_sync_calls_run_off_the_event_loop(adapter, hub):
main_thread = threading.get_ident()
def whoami(**kw):
assert threading.get_ident() != main_thread
return EXPERIMENT_PAYLOAD
hub.route("ExperimentsApi", "*", whoami)
await adapter.get_experiment(123)
async def test_steps_endpoint_returns_steps_schema(adapter, hub):
hub.route(
"StepsApi",
"*",
lambda **kw: [{"id": 9, "body": "Add ethanol", "finished": False, "ordering": 1}],
)
steps = await adapter.get_steps("experiments", 123)
assert steps[0].id == 9
async def test_containers_endpoint_returns_container_links(adapter, hub):
hub.route(
"ContainersApi",
"*",
lambda **kw: [{"id": 31, "qty_stored": 50.0, "unit": "mL"}],
)
containers = await adapter.get_containers("items", 12)
assert containers[0].id == 31
assert containers[0].qty_stored == 50.0
async def test_search_normalizes_pagination(adapter, hub):
seen = {}
def capture(**kwargs):
seen.update(kwargs)
return [EXPERIMENT_PAYLOAD]
hub.route("ExperimentsApi", "*", capture)
results = await adapter.search_experiments(q="plasmid", limit=5, offset=10)
assert results[0].id == 123
assert seen, "search must reach the client"
assert "q" in str(seen) or "query" in str(seen)
async def test_client_keeps_settings(settings):
client = ElabftwClient(settings)
assert client.settings is settings
+64
View File
@@ -0,0 +1,64 @@
"""errors.py — typed errors with voice-friendly messages (plan §12)."""
from mcp_server.errors import (
AnnotationError,
ApiError,
AuthError,
ClarificationNeededError,
ErrorKind,
InsufficientStockError,
LabVoiceError,
NotFoundError,
PermissionDeniedError,
)
def test_every_error_kind_maps_to_its_class():
assert AuthError.kind is ErrorKind.AUTH
assert PermissionDeniedError.kind is ErrorKind.PERMISSION
assert NotFoundError.kind is ErrorKind.NOT_FOUND
assert ApiError.kind is ErrorKind.API
assert AnnotationError.kind is ErrorKind.ANNOTATION
assert ClarificationNeededError.kind is ErrorKind.CLARIFICATION
assert InsufficientStockError.kind is ErrorKind.INSUFFICIENT_STOCK
assert LabVoiceError.kind is ErrorKind.API
def test_kind_values_are_the_wire_strings():
assert ErrorKind.AUTH == "auth_error"
assert ErrorKind.PERMISSION == "permission_error"
assert ErrorKind.NOT_FOUND == "not_found"
assert ErrorKind.CLARIFICATION == "clarification"
assert ErrorKind.INSUFFICIENT_STOCK == "insufficient_stock"
def test_message_and_detail_are_preserved():
err = NotFoundError("Experiment 123 not found.", detail="GET /experiments/123")
assert str(err) == "Experiment 123 not found."
assert err.message == "Experiment 123 not found."
assert err.detail == "GET /experiments/123"
def test_all_labvoice_errors_share_the_base():
for cls in (
AuthError,
PermissionDeniedError,
NotFoundError,
ApiError,
AnnotationError,
ClarificationNeededError,
InsufficientStockError,
):
assert issubclass(cls, LabVoiceError)
def test_clarification_options_default_empty_and_are_preserved():
err = ClarificationNeededError("Which ethanol?")
assert err.options == []
err = ClarificationNeededError("Which ethanol?", options=["Absolute Ethanol", "96%"])
assert err.options == ["Absolute Ethanol", "96%"]
def test_error_messages_are_voice_friendly_strings():
err = PermissionDeniedError("This API key is read-only; I can't finish the step.")
assert isinstance(err.message, str) and err.message
+135
View File
@@ -0,0 +1,135 @@
"""journal.py — SQLite saga journal: idempotency, statuses, durability (plan §10)."""
import pytest
from mcp_server.journal import ActionStatus, OperationStatus, SagaJournal
@pytest.fixture
async def fresh_journal(tmp_path):
journal = SagaJournal(tmp_path / "saga.sqlite")
await journal.connect()
yield journal
await journal.close()
async def test_connect_is_idempotent_safe_for_new_db(fresh_journal):
record = await fresh_journal.begin_operation("complete_step", "op-1")
assert record.status is OperationStatus.PLANNED
assert record.kind == "complete_step"
assert record.actions == []
async def test_begin_generates_operation_id_when_none_given(fresh_journal):
record = await fresh_journal.begin_operation("complete_step")
assert record.operation_id
async def test_record_and_mark_actions(fresh_journal):
await fresh_journal.begin_operation("complete_step", "op-1")
action = await fresh_journal.record_action(
"op-1", "decrement_container", {"item_id": 12, "subitem_id": 31, "qty_stored": 48.0}
)
assert action.status is ActionStatus.PLANNED
assert action.payload["subitem_id"] == 31
marked = await fresh_journal.mark_action(
"op-1", action.id, ActionStatus.DONE, response={"ok": True}
)
assert marked.status is ActionStatus.DONE
assert marked.response == {"ok": True}
async def test_get_operation_returns_actions_in_order(fresh_journal):
await fresh_journal.begin_operation("complete_step", "op-1")
first = await fresh_journal.record_action("op-1", "decrement_container", {"subitem_id": 31})
second = await fresh_journal.record_action("op-1", "finish_step", {"step_id": 9})
third = await fresh_journal.record_action("op-1", "post_comment", {"body": "done"})
record = await fresh_journal.get_operation("op-1")
assert [a.id for a in record.actions] == [first.id, second.id, third.id]
assert [a.action for a in record.actions] == [
"decrement_container",
"finish_step",
"post_comment",
]
async def test_has_completed_only_after_finish(fresh_journal):
await fresh_journal.begin_operation("complete_step", "op-1")
action = await fresh_journal.record_action("op-1", "decrement_container", {})
await fresh_journal.mark_action("op-1", action.id, ActionStatus.DONE)
assert await fresh_journal.has_completed("op-1") is False
await fresh_journal.finish_operation("op-1", OperationStatus.COMPLETED)
assert await fresh_journal.has_completed("op-1") is True
async def test_begin_with_completed_operation_id_is_idempotent_replay(fresh_journal):
"""Plan §13: double execution of the same operation_id is idempotent."""
await fresh_journal.begin_operation("complete_step", "op-1")
await fresh_journal.record_action("op-1", "decrement_container", {"subitem_id": 31})
await fresh_journal.finish_operation("op-1", OperationStatus.COMPLETED)
replayed = await fresh_journal.begin_operation("complete_step", "op-1")
assert replayed.status is OperationStatus.COMPLETED
assert len(replayed.actions) == 1
record = await fresh_journal.get_operation("op-1")
assert len(record.actions) == 1, "replay must not append new actions"
async def test_begin_with_inprogress_operation_id_resumes_it(fresh_journal):
await fresh_journal.begin_operation("complete_step", "op-1")
await fresh_journal.record_action("op-1", "decrement_container", {"subitem_id": 31})
resumed = await fresh_journal.begin_operation("complete_step", "op-1")
assert resumed.status is OperationStatus.PLANNED
assert len(resumed.actions) == 1
async def test_failed_and_compensated_statuses_persist(fresh_journal):
await fresh_journal.begin_operation("complete_step", "op-1")
action = await fresh_journal.record_action("op-1", "finish_step", {"step_id": 9})
await fresh_journal.mark_action("op-1", action.id, ActionStatus.FAILED)
await fresh_journal.finish_operation("op-1", OperationStatus.COMPENSATED)
record = await fresh_journal.get_operation("op-1")
assert record.status is OperationStatus.COMPENSATED
assert record.actions[0].status is ActionStatus.FAILED
assert await fresh_journal.has_completed("op-1") is False
async def test_partial_failure_status_roundtrip(fresh_journal):
"""Plan §10: compensation failure ⇒ partial_failure in the journal."""
await fresh_journal.begin_operation("complete_step", "op-1")
await fresh_journal.finish_operation("op-1", OperationStatus.PARTIAL_FAILURE)
record = await fresh_journal.get_operation("op-1")
assert record.status is OperationStatus.PARTIAL_FAILURE
async def test_journal_persists_across_reconnect(tmp_path):
"""The journal doubles as the audit log — it must survive restarts."""
path = tmp_path / "saga.sqlite"
journal = SagaJournal(path)
await journal.connect()
await journal.begin_operation("complete_step", "op-1")
action = await journal.record_action("op-1", "decrement_container", {"subitem_id": 31})
await journal.mark_action("op-1", action.id, ActionStatus.DONE, response={"qty": 48.0})
await journal.finish_operation("op-1", OperationStatus.COMPLETED)
await journal.close()
reopened = SagaJournal(path)
await reopened.connect()
try:
record = await reopened.get_operation("op-1")
assert record.kind == "complete_step"
assert record.status is OperationStatus.COMPLETED
assert record.actions[0].response == {"qty": 48.0}
assert await reopened.has_completed("op-1") is True
finally:
await reopened.close()
async def test_get_operation_of_unknown_id_raises(fresh_journal):
with pytest.raises(Exception):
await fresh_journal.get_operation("nope")
+72
View File
@@ -0,0 +1,72 @@
"""models.py — compact result shapes (plan §6: ids, titles, quantities, statuses)."""
import pytest
from pydantic import ValidationError
from mcp_server.models import (
AllocationPolicy,
CompleteStepResult,
Consumable,
HandoffContext,
NextStep,
ProtocolAnnotation,
StepInfo,
)
def test_consumable_defaults_to_fifo_and_required():
consumable = Consumable(resource_key="ethanol_absolute", quantity=2.0, unit="mL")
assert consumable.allocation is AllocationPolicy.FIFO
assert consumable.optional is False
assert consumable.container_id is None
def test_consumable_quantity_may_be_null_for_prompted_steps():
consumable = Consumable(resource_key="x", quantity=None, unit="mL")
assert consumable.quantity is None
def test_consumable_rejects_missing_required_fields():
with pytest.raises(ValidationError):
Consumable(resource_key="x") # quantity and unit missing
def test_annotation_defaults():
annotation = ProtocolAnnotation()
assert annotation.consumables == []
assert annotation.prompt_quantity is False
def test_step_info_defaults_unfinished():
step = StepInfo(id=9, body="Add ethanol")
assert step.finished is False
assert step.position is None
def test_complete_step_result_status_is_restricted():
for status in ("completed", "protocol_complete", "reverted", "partial_failure"):
CompleteStepResult(
experiment_id=123,
status=status,
step=StepInfo(id=9, body="b"),
)
with pytest.raises(ValidationError):
CompleteStepResult(
experiment_id=123,
status="exploded",
step=StepInfo(id=9, body="b"),
)
def test_next_step_carries_consumables_and_stock():
next_step = NextStep(experiment_id=123, step=StepInfo(id=9, body="Add ethanol"))
assert next_step.consumables == []
assert next_step.stock == []
def test_handoff_context_is_id_only_and_rest_optional():
"""Plan §8: the app forwards only ids; device/location are optional extras."""
handoff = HandoffContext(experiment_id=123, user_id=2)
assert handoff.step_id is None
assert handoff.device_id is None
assert handoff.location_id is None
+89
View File
@@ -0,0 +1,89 @@
"""resolve.py — resource_key → item id precedence and ambiguity (plan §5).
Precedence: local mapping store (authoritative at execution) → resource marker
→ (setup-time-only matchers/title, never silently applied at runtime).
Ambiguous or missing ⇒ ClarificationNeededError listing candidates.
"""
import pytest
from fakes import make_item
from mcp_server.errors import ClarificationNeededError
async def test_resolve_via_marker_when_store_is_empty(client, real_resolver):
"""Marker comment in the resource body is discovered without any mapping."""
assert await real_resolver.resolve("ethanol_absolute") == 12
async def test_mapping_store_is_authoritative_over_marker(client, real_resolver):
await real_resolver.upsert_mapping("ethanol_absolute", 12)
# Even if another item also carries the marker, the store binding wins.
client.items[13] = make_item(id=13, title="Ethanol bottle (old)", body="<!-- labvoice:resource-key=ethanol_absolute -->")
assert await real_resolver.resolve("ethanol_absolute") == 12
async def test_resolve_unknown_key_raises_clarification(client, real_resolver):
with pytest.raises(ClarificationNeededError) as excinfo:
await real_resolver.resolve("sodium_azide")
assert "sodium_azide" in str(excinfo.value)
async def test_resolve_ambiguous_marker_raises_clarification_with_candidates(client, real_resolver):
client.items[13] = make_item(id=13, title="Ethanol bottle 2", body="<!-- labvoice:resource-key=ethanol_absolute -->")
with pytest.raises(ClarificationNeededError) as excinfo:
await real_resolver.resolve("ethanol_absolute")
assert excinfo.value.options, "clarification must list candidate resources"
async def test_upsert_mapping_is_idempotent(client, real_resolver):
await real_resolver.upsert_mapping("ethanol_absolute", 12)
await real_resolver.upsert_mapping("ethanol_absolute", 12)
mappings = await real_resolver.list_mappings()
assert [m.item_id for m in mappings if m.resource_key == "ethanol_absolute"] == [12]
async def test_upsert_mapping_updates_existing_binding(client, real_resolver):
await real_resolver.upsert_mapping("ethanol_absolute", 12)
await real_resolver.upsert_mapping("ethanol_absolute", 99)
assert await real_resolver.resolve("ethanol_absolute") == 99
async def test_list_mappings_empty_store(real_resolver):
assert await real_resolver.list_mappings() == []
async def test_export_mapping_produces_portable_json(client, real_resolver):
await real_resolver.upsert_mapping("ethanol_absolute", 12)
data = await real_resolver.export_mapping()
assert "ethanol_absolute" in data
entry = data["ethanol_absolute"]
assert entry.get("title") in (None, "Absolute Ethanol")
async def test_import_mapping_returns_proposals(client, real_resolver):
data = {"ethanol_absolute": {"cas": "64-17-5", "title": "Absolute Ethanol", "unit": "mL"}}
proposals = await real_resolver.import_mapping(data)
assert proposals
assert proposals[0].resource_key == "ethanol_absolute"
async def test_find_candidates_finds_marker_match(client, real_resolver):
proposals = await real_resolver.find_candidates("ethanol_absolute")
assert any(p.item_id == 12 for p in proposals)
async def test_find_candidates_finds_exact_title_match(client, real_resolver):
"""Exact title match is a setup-time suggestion only — proposals carry scores."""
client.items[14] = make_item(id=14, title="sodium_azide", body="")
proposals = await real_resolver.find_candidates("sodium_azide")
assert any(p.item_id == 14 for p in proposals)
proposal = next(p for p in proposals if p.item_id == 14)
assert proposal.score is not None
async def test_exact_title_never_applies_silently_at_runtime(client, real_resolver):
"""An exact title match alone must not create a runtime binding (plan §5.4)."""
client.items[14] = make_item(id=14, title="sodium_azide", body="")
with pytest.raises(ClarificationNeededError):
await real_resolver.resolve("sodium_azide")
+184
View File
@@ -0,0 +1,184 @@
"""rest.py — read-only REST mirror for the mobile app (plan §8, §13).
Bearer-token protected, mutations never exposed, same compact shapes and typed
errors as the MCP tools. Tests use Starlette's TestClient (ASGI, no network).
Convention under test: the app resolves its read services via
``mcp_server.server.build_services`` — monkeypatched here (see conftest).
"""
import pytest
from fastapi import FastAPI
from starlette.testclient import TestClient
from mcp_server.errors import LabVoiceError
TOKEN = "app-bearer-token-123"
AUTH = {"Authorization": f"Bearer {TOKEN}"}
@pytest.fixture
def rest_settings(settings):
settings.labvoice_rest_token = TOKEN
return settings
@pytest.fixture
def rest_app(rest_settings, patch_build_services) -> FastAPI:
patch_build_services()
from mcp_server.rest import create_rest_app
return create_rest_app(rest_settings)
@pytest.fixture
def api(rest_app) -> TestClient:
return TestClient(rest_app)
# --- auth --------------------------------------------------------------------
def test_missing_token_is_rejected(api):
assert api.get("/api/experiments").status_code == 401
assert api.get("/api/experiments/123/state").status_code == 401
assert api.get("/api/experiments/123/next-step").status_code == 401
assert api.get("/api/templates").status_code == 401
def test_wrong_token_is_rejected(api):
response = api.get("/api/experiments", headers={"Authorization": "Bearer nope"})
assert response.status_code == 401
def test_correct_token_passes(api):
response = api.get("/api/experiments", headers=AUTH)
assert response.status_code == 200
def test_rest_app_requires_token_configured(settings, patch_build_services):
patch_build_services()
from mcp_server.rest import create_rest_app
settings.labvoice_rest_token = None
with pytest.raises(Exception) as excinfo:
create_rest_app(settings)
assert not isinstance(excinfo.value, NotImplementedError)
# --- endpoints ----------------------------------------------------------------
def test_find_experiments_returns_compact_summaries(api):
response = api.get("/api/experiments", params={"q": "plasmid"}, headers=AUTH)
assert response.status_code == 200
payload = response.json()
assert isinstance(payload, list) and payload
summary = payload[0]
assert summary["id"] == 123
assert summary["title"] == "Plasmid prep"
def test_next_step_is_the_hot_path(api):
"""Plan §8: GET /api/experiments/{id}/next-step returns the parsed step + stock."""
response = api.get("/api/experiments/123/next-step", headers=AUTH)
assert response.status_code == 200
payload = response.json()
assert payload["experiment_id"] == 123
assert payload["step"]["id"] == 9
assert payload["step"]["finished"] is False
assert payload["consumables"][0]["resource_key"] == "ethanol_absolute"
assert payload["consumables"][0]["quantity"] == 2.0
assert payload["stock"][0]["container_id"] == 31
assert payload["stock"][0]["available"] == 50.0
def test_next_step_null_when_protocol_complete(api, client):
for step in client.experiments[123].steps:
step.finished = True
response = api.get("/api/experiments/123/next-step", headers=AUTH)
assert response.status_code == 200
assert response.json() is None
def test_state_snapshot_shape(api):
"""Plan §8: `state` is what the app renders: title, steps, stock, next step."""
response = api.get("/api/experiments/123/state", headers=AUTH)
assert response.status_code == 200
state = response.json()
assert state["experiment_id"] == 123
assert state["title"] == "Plasmid prep"
assert state["unfinished_steps"] == [9, 10]
next_step = state["next_step"]
assert set(next_step) == {"experiment_id", "step", "consumables", "stock"}
assert next_step["step"]["id"] == 9
assert len(next_step["consumables"]) == 1
assert {c["container_id"] for c in next_step["stock"]} == {31, 32}
def test_state_of_unknown_experiment_is_typed_error(api):
response = api.get("/api/experiments/9999/state", headers=AUTH)
assert response.status_code == 404
payload = response.json()
assert payload["error"] == "not_found"
assert "message" in payload
def test_templates_endpoint_lists_summaries(api, client):
from fakes import make_template
client.templates[7] = make_template()
response = api.get("/api/templates", headers=AUTH)
assert response.status_code == 200
templates = response.json()
summary = templates[0]
assert summary["id"] == 7
assert summary["title"] == "PCR cleanup"
assert "short_description" in summary
assert summary["tags"] == ["dna", "cleanup"]
def test_templates_search_ranks(api, client):
from fakes import make_template
client.templates[7] = make_template()
response = api.get("/api/templates", params={"q": "pcr"}, headers=AUTH)
assert response.status_code == 200
assert response.json()[0]["id"] == 7
# --- mutations are never exposed ----------------------------------------------
def test_no_mutation_routes_exist(rest_app):
"""Plan §8: mutations exist only as MCP tools so every mutation is journaled."""
paths = rest_app.openapi().get("paths", {})
assert paths, "REST app must document its endpoints"
for path, operations in paths.items():
for method in operations:
assert method.lower() == "get", f"{method.upper()} {path} must not exist"
# --- unit-level helpers --------------------------------------------------------
def test_error_payload_shape():
from mcp_server.rest import error_payload
assert error_payload("clarification", "Which ethanol?") == {
"error": "clarification",
"message": "Which ethanol?",
}
def test_verify_token_accepts_configured_token(rest_settings):
from mcp_server.rest import verify_token
assert verify_token(TOKEN) is None
def test_verify_token_rejects_wrong_token(rest_settings):
from mcp_server.rest import verify_token
with pytest.raises(LabVoiceError):
verify_token("wrong")
+97
View File
@@ -0,0 +1,97 @@
"""Safety checks as tests (plan §13).
* a read-only API key must fail cleanly on every mutating tool
* failed workflows leave no partial state (no comment, no finished step)
* the API key never appears in any tool result
"""
import json
import pytest
from mcp.client import Client
from fakes import make_template
from mcp_server.tools import register_tools
TOKEN_SAFE_MESSAGE = "labvoice-secret-key-do-not-leak"
MUTATING_TOOLS = {
"complete_next_protocol_step": {"experiment_id": 123},
"complete_protocol_step": {"experiment_id": 123, "step_id": 9},
"record_protocol_observation": {"experiment_id": 123, "comment": "note"},
"create_experiment_from_template": {"template_id": 7},
"adjust_inventory": {"amount": 5.0, "unit": "mL", "container_id": 31},
}
@pytest.fixture
async def mcp_client(patch_build_services, client):
from mcp.server.fastmcp import FastMCP
patch_build_services()
client.templates[7] = make_template()
mcp = FastMCP("labvoice-safety")
register_tools(mcp)
async with Client(mcp, raise_exceptions=False) as c:
yield c
def _state_fingerprint(client):
experiment = client.experiments[123]
return {
"steps": [(s.id, s.finished) for s in experiment.steps],
"comments": [c.body for c in experiment.comments],
"containers": [
(i.id, c.sub_id, c.qty_stored)
for i in client.items.values()
for c in i.containers
],
}
@pytest.mark.parametrize("tool", sorted(MUTATING_TOOLS))
async def test_read_only_key_fails_cleanly_on_every_mutating_tool(
mcp_client, client, tool
):
"""Plan §13: read-only key ⇒ every mutation fails with a clear message."""
client.read_only = True
before = _state_fingerprint(client)
result = await mcp_client.call_tool(tool, MUTATING_TOOLS[tool])
payload = None
structured = getattr(result, "structured_content", None)
texts = " ".join(getattr(b, "text", "") for b in getattr(result, "content", []))
if structured is not None:
payload = structured
assert getattr(result, "is_error", False) or (
payload is not None and payload.get("ok") is False
) or "permission" in texts.lower() or "read-only" in texts.lower(), (
f"{tool} must not silently succeed with a read-only key"
)
assert _state_fingerprint(client) == before, f"{tool} must not mutate anything"
async def test_aborted_workflow_leaves_no_partial_state(mcp_client, client, resolver_stub):
"""Unresolvable resource ⇒ no finished step, no comment, no stock change."""
resolver_stub.mappings.clear()
before = _state_fingerprint(client)
result = await mcp_client.call_tool(
"complete_next_protocol_step", {"experiment_id": 123}
)
texts = " ".join(getattr(b, "text", "") for b in getattr(result, "content", []))
assert result.is_error or "clarification" in texts.lower() or "not know" in texts.lower()
assert _state_fingerprint(client) == before
async def test_error_results_never_contain_the_api_key(mcp_client, client, resolver_stub):
client.read_only = True
resolver_stub.mappings.clear()
for tool, args in MUTATING_TOOLS.items():
result = await mcp_client.call_tool(tool, dict(args))
texts = " ".join(getattr(b, "text", "") for b in getattr(result, "content", []))
structured = getattr(result, "structured_content", None)
blob = texts + json.dumps(structured, default=str)
assert TOKEN_SAFE_MESSAGE not in blob
+132
View File
@@ -0,0 +1,132 @@
"""templates.py — template search/ranking and creation flow (plan §9, §13)."""
import pytest
from fakes import FakeElabftwClient, FakeTemplate, make_template
from mcp_server.errors import NotFoundError
def ranking_client() -> FakeElabftwClient:
return FakeElabftwClient(
templates=[
FakeTemplate(id=1, title="Cell culture", description="Maintain adherent lines."),
FakeTemplate(
id=2,
title="Protein extraction",
description="Lyse cells; collect supernatant in pcr tubes.",
),
FakeTemplate(id=3, title="Ethanol precipitation", description="Precipitate DNA.", tags=["pcr"]),
FakeTemplate(id=4, title="PCR cleanup", description="Purify an amplicon."),
]
)
# --- short description ------------------------------------------------------
async def test_short_description_is_first_paragraph(templates_service, client):
client.templates[7] = make_template()
result = await templates_service.list_templates()
assert result[0].short_description == "Purify an amplicon after PCR."
async def test_short_description_truncated_to_limit(templates_service, settings, client):
settings.labvoice_template_desc_limit = 20
client.templates[7] = make_template(
description="P" * 100 + "\n\nSecond paragraph that must not appear."
)
result = await templates_service.list_templates()
summary = result[0]
assert len(summary.short_description) <= 20
assert "Second paragraph" not in summary.short_description
async def test_short_description_empty_when_no_description(templates_service, client):
client.templates[7] = make_template(description="")
result = await templates_service.list_templates()
assert result[0].short_description == ""
async def test_summary_carries_id_title_tags(templates_service, client):
client.templates[7] = make_template()
result = await templates_service.list_templates()
summary = result[0]
assert summary.id == 7
assert summary.title == "PCR cleanup"
assert summary.tags == ["dna", "cleanup"]
# --- ranking ----------------------------------------------------------------
async def test_no_query_preserves_listing_order(templates_service, client):
client.templates.update({t.id: t for t in ranking_client().templates.values()})
result = await templates_service.list_templates()
assert [t.id for t in result] == [1, 2, 3, 4]
async def test_title_match_ranks_above_tag_and_description_match(templates_service, client):
client.templates.update({t.id: t for t in ranking_client().templates.values()})
result = await templates_service.list_templates(query="pcr")
ids = [t.id for t in result]
assert ids.index(4) < ids.index(3) < ids.index(2), (
"expected title(4) > tags(3) > description(2) per plan §9 weights"
)
async def test_non_matching_templates_rank_below_matches(templates_service, client):
client.templates.update({t.id: t for t in ranking_client().templates.values()})
result = await templates_service.list_templates(query="pcr")
assert result[-1].id == 1
async def test_limit_is_respected(templates_service, client):
client.templates.update({t.id: t for t in ranking_client().templates.values()})
result = await templates_service.list_templates(query="pcr", limit=2)
assert len(result) == 2
assert result[0].id == 4
# --- creation ----------------------------------------------------------------
async def test_create_from_template_returns_experiment_and_first_step(templates_service, client):
client.templates[7] = make_template()
result = await templates_service.create_experiment_from_template(7)
assert result.ok is True
assert result.experiment_id == 1000
assert result.title == "PCR cleanup"
assert result.first_step is not None
assert result.first_step.id == 41
assert result.first_step.finished is False
async def test_create_with_explicit_title(templates_service, client):
client.templates[7] = make_template()
result = await templates_service.create_experiment_from_template(7, title="My amplicon run")
assert result.title == "My amplicon run"
async def test_created_experiment_copies_template_steps_and_annotations(
templates_service, client
):
"""eLabFTW copies template steps — annotations must come along (plan §9)."""
from fakes import ANNOTATION_ETHANOL_FIFO, FakeStep
client.templates[7] = make_template(
steps=[FakeStep(id=41, body=f"Add ethanol\n{ANNOTATION_ETHANOL_FIFO}", ordering=1)]
)
result = await templates_service.create_experiment_from_template(7)
created = client.experiments[result.experiment_id]
assert "labvoice:v1" in created.steps[0].body
async def test_create_from_unknown_template_fails_cleanly(templates_service, client):
with pytest.raises((NotFoundError, KeyError)):
await templates_service.create_experiment_from_template(9999)
async def test_search_index_build_is_optional(templates_service, client):
"""Lexical fallback works without an explicitly built index (plan §9 fallback)."""
client.templates.update({t.id: t for t in ranking_client().templates.values()})
result = await templates_service.list_templates(query="pcr")
assert result[0].id == 4
+169
View File
@@ -0,0 +1,169 @@
"""tools.py + MCP surface — small-model-friendly tool schemas (plan §6, §13).
Checks the tool *surface* (names, short descriptions, small input schemas)
over the real FastMCP server using the in-process client, plus a golden
result for the headline workflow.
Note: the plan's "ten tools" are the reads + aggregates; the scaffold adds
four admin/setup tools (scan/map/export/import), so the registered surface
is fourteen functions.
"""
import json
import pytest
from mcp.client import Client
from mcp_server.tools import TOOL_FUNCTIONS, register_tools
EXPECTED_TOOLS = {
"find_experiments",
"get_experiment_context",
"get_next_protocol_step",
"list_experiment_templates",
"validate_protocol_template",
"complete_next_protocol_step",
"complete_protocol_step",
"record_protocol_observation",
"create_experiment_from_template",
"adjust_inventory",
"scan_instance",
"map_resource",
"export_mapping",
"import_mapping",
}
def result_json(call_result):
"""Extract the JSON payload from a CallToolResult (structured or text)."""
structured = getattr(call_result, "structured_content", None)
if structured is not None:
return structured
for block in call_result.content:
text = getattr(block, "text", None)
if text:
return json.loads(text)
raise AssertionError(f"No usable content in tool result: {call_result!r}")
@pytest.fixture
async def mcp_server(patch_build_services):
from mcp.server.fastmcp import FastMCP
patch_build_services()
mcp = FastMCP("labvoice-test")
register_tools(mcp)
return mcp
@pytest.fixture
async def mcp_client(mcp_server):
async with Client(mcp_server, raise_exceptions=True) as client:
yield client
# --- tool surface ------------------------------------------------------------
async def test_all_tools_registered_and_listed(mcp_client):
tools = await mcp_client.list_tools()
names = {t.name for t in tools}
assert names == EXPECTED_TOOLS
assert len(TOOL_FUNCTIONS) == len(EXPECTED_TOOLS)
async def test_every_tool_has_a_short_description(mcp_client):
"""Plan §6: tool descriptions are one or two short sentences."""
for tool in await mcp_client.list_tools():
assert tool.description, f"{tool.name} needs a description"
assert len(tool.description) <= 300, f"{tool.name} description too long for a small model"
async def test_input_schemas_are_small(mcp_client):
"""Plan §13 lint: small input schemas — no kitchen-sink parameter lists."""
for tool in await mcp_client.list_tools():
properties = tool.inputSchema.get("properties", {})
assert len(properties) <= 5, f"{tool.name} takes {len(properties)} parameters"
assert tool.inputSchema.get("type") == "object"
async def test_input_schemas_match_function_signatures(mcp_client):
import inspect
for tool in await mcp_client.list_tools():
fn = next(f for f in TOOL_FUNCTIONS if f.__name__ == tool.name)
params = inspect.signature(fn).parameters
properties = tool.inputSchema.get("properties", {})
assert set(properties) == set(params), f"{tool.name}: schema/args mismatch"
required = set(tool.inputSchema.get("required", []))
expected_required = {
name for name, p in params.items() if p.default is inspect.Parameter.empty
}
assert required == expected_required, f"{tool.name}: required set mismatch"
async def test_mutating_tools_are_clearly_named(mcp_client):
"""A small model must not confuse reads with mutations (plan §2)."""
mutating = {
"complete_next_protocol_step",
"complete_protocol_step",
"record_protocol_observation",
"create_experiment_from_template",
"adjust_inventory",
"map_resource",
"scan_instance",
"import_mapping",
}
tools = {t.name: t for t in await mcp_client.list_tools()}
for name in mutating:
assert name in tools
# --- golden workflow result over the MCP transport -----------------------------
async def test_headline_workflow_golden_result_over_mcp(mcp_client, client):
"""Plan §13: golden result for complete_next_protocol_step (§7)."""
call_result = await mcp_client.call_tool(
"complete_next_protocol_step",
{"experiment_id": 123, "comment": "Done at the bench"},
)
payload = result_json(call_result)
assert payload["ok"] is True
assert payload["status"] == "completed"
assert payload["experiment_id"] == 123
assert payload["step"]["id"] == 9
assert payload["step"]["finished"] is True
assert payload["consumed"][0]["resource_key"] == "ethanol_absolute"
assert payload["consumed"][0]["container_id"] == 31
assert payload["consumed"][0]["amount"] == "2.0 mL"
assert payload["consumed"][0]["remaining"] == "48.0 mL"
assert payload["next_step"]["id"] == 10
# stock really moved in eLabFTW
assert client.items[12].containers[0].qty_stored == 48.0
async def test_read_tool_over_mcp_returns_compact_json(mcp_client):
call_result = await mcp_client.call_tool("get_next_protocol_step", {"experiment_id": 123})
payload = result_json(call_result)
assert payload["step"]["id"] == 9
assert payload["consumables"][0]["resource_key"] == "ethanol_absolute"
async def test_clarification_over_mcp_is_a_typed_error(mcp_client, resolver_stub):
resolver_stub.mappings.clear()
call_result = await mcp_client.call_tool(
"complete_next_protocol_step", {"experiment_id": 123}
)
payload = result_json(call_result)
# Either the transport surfaces the error or the payload carries it —
# both are acceptable, but it must be the clarification kind either way.
if getattr(call_result, "is_error", False):
text = " ".join(getattr(b, "text", "") for b in call_result.content)
assert "clarification" in text or "ethanol" in text
else:
assert payload.get("error") == "clarification" or payload.get("status") in (
"clarification",
"reverted",
)
+92
View File
@@ -0,0 +1,92 @@
"""workflows/inventory.py — stock reads + adjust_inventory (plan §6, §10)."""
import pytest
from mcp_server.errors import ClarificationNeededError, NotFoundError
# --- stock reads ------------------------------------------------------------
async def test_stock_for_resource_lists_containers(inventory):
stock = await inventory.stock_for_resource("ethanol_absolute")
assert [s.container_id for s in stock] == [31, 32]
assert stock[0].available == 50.0
assert stock[0].unit == "mL"
assert stock[0].amount == "50.0 mL"
async def test_stock_for_unknown_resource_raises_clarification(inventory, resolver_stub):
resolver_stub.mappings.clear()
with pytest.raises(ClarificationNeededError):
await inventory.stock_for_resource("unobtainium")
# --- adjust_inventory -------------------------------------------------------
async def test_adjust_adds_to_container_by_id(inventory, client):
result = await inventory.adjust_inventory(amount=500.0, unit="mL", container_id=31)
assert result.ok is True
assert result.container_id == 31
assert result.amount == "500.0 mL"
assert result.remaining == "550.0 mL"
assert client.items[12].containers[0].qty_stored == 550.0
async def test_adjust_supports_correction_negative_delta(inventory, client):
await inventory.adjust_inventory(amount=-2.0, unit="mL", container_id=31)
assert client.items[12].containers[0].qty_stored == 48.0
async def test_adjust_by_resource_key_uses_allocable_container(inventory, client):
result = await inventory.adjust_inventory(
amount=100.0, unit="mL", resource_key="ethanol_absolute"
)
assert result.resource_key == "ethanol_absolute"
assert result.container_id == 31
assert result.remaining == "150.0 mL"
async def test_adjust_converts_units(inventory, client):
"""“Add half a litre” against an mL-stocked container."""
await inventory.adjust_inventory(amount=0.5, unit="L", container_id=31)
assert client.items[12].containers[0].qty_stored == 550.0
async def test_adjust_rejects_non_whitelisted_unit(inventory):
with pytest.raises(ClarificationNeededError):
await inventory.adjust_inventory(amount=1.0, unit="fl-oz", container_id=31)
async def test_adjust_rejects_cross_dimension_unit(inventory):
with pytest.raises(ClarificationNeededError):
await inventory.adjust_inventory(amount=5.0, unit="g", container_id=31)
async def test_adjust_unknown_container_fails_cleanly(inventory):
with pytest.raises(NotFoundError):
await inventory.adjust_inventory(amount=1.0, unit="mL", container_id=9999)
async def test_adjust_requires_container_or_resource(inventory):
with pytest.raises(ClarificationNeededError):
await inventory.adjust_inventory(amount=1.0, unit="mL")
async def test_adjust_is_journaled(inventory, journal):
await inventory.adjust_inventory(amount=500.0, unit="mL", container_id=31)
assert journal.db_path.exists()
async def test_adjust_failure_does_not_silently_lose_data(inventory, client, monkeypatch):
"""If the write fails, the workflow surfaces the error (journaled saga)."""
from mcp_server.errors import ApiError
async def fail_write(entity_type, entity_id, subitem_id, qty_stored):
raise ApiError("write refused")
monkeypatch.setattr(inventory.client, "set_container_quantity", fail_write)
with pytest.raises(ApiError):
await inventory.adjust_inventory(amount=500.0, unit="mL", container_id=31)
assert client.items[12].containers[0].qty_stored == 50.0
+338
View File
@@ -0,0 +1,338 @@
"""workflows/protocol.py — the step-completion saga (plan §7, §10).
Golden path: select next step → parse annotation → resolve keys → allocate →
validate stock → decrement containers → finish step → post comment → verify.
Failure semantics: clarifications abort before any mutation; journal failures
compensate (reverted) or escalate (partial_failure + audit comment).
"""
import pytest
from fakes import ANNOTATION_ETHANOL_FIFO, FakeContainer, FakeStep
from mcp_server.errors import (
ApiError,
ClarificationNeededError,
InsufficientStockError,
NotFoundError,
PermissionDeniedError,
)
# --- get_next_step ----------------------------------------------------------
async def test_next_step_is_lowest_ordering_unfinished(protocol):
next_step = await protocol.get_next_step(123)
assert next_step is not None
assert next_step.experiment_id == 123
assert next_step.step.id == 9
assert next_step.step.body.startswith("Add ethanol")
async def test_next_step_includes_parsed_consumables(protocol):
next_step = await protocol.get_next_step(123)
assert len(next_step.consumables) == 1
assert next_step.consumables[0].resource_key == "ethanol_absolute"
assert next_step.consumables[0].quantity == 2.0
async def test_next_step_includes_stock_preview(protocol):
next_step = await protocol.get_next_step(123)
assert [s.container_id for s in next_step.stock] == [31, 32]
assert next_step.stock[0].available == 50.0
assert next_step.stock[0].unit == "mL"
async def test_next_step_none_when_protocol_complete(protocol, client):
for step in client.experiments[123].steps:
step.finished = True
assert await protocol.get_next_step(123) is None
async def test_next_step_skips_finished_steps(protocol, client):
client.experiments[123].steps[0].finished = True
next_step = await protocol.get_next_step(123)
assert next_step.step.id == 10
async def test_next_step_unknown_experiment_fails_cleanly(protocol):
with pytest.raises(NotFoundError):
await protocol.get_next_step(9999)
# --- complete_next_step: golden path ----------------------------------------
async def test_golden_path_completes_step_and_consumes_stock(protocol, client):
result = await protocol.complete_next_step(123, comment="Done at the bench")
assert result.ok is True
assert result.status == "completed"
assert result.experiment_id == 123
assert result.step.id == 9
assert result.step.finished is True
assert result.next_step is not None
assert result.next_step.id == 10
# stock decremented (fifo: container 31 first)
assert client.items[12].containers[0].qty_stored == 48.0
assert client.items[12].containers[1].qty_stored == 10.0
# compact consumed record with TTS-ready amounts (plan §6)
assert len(result.consumed) == 1
consumed = result.consumed[0]
assert consumed.resource_key == "ethanol_absolute"
assert consumed.container_id == 31
assert consumed.amount == "2.0 mL"
assert consumed.remaining == "48.0 mL"
# comment posted
assert result.comment_id is not None
comments = client.experiments[123].comments
assert any("Done at the bench" in c.body for c in comments)
async def test_step_is_marked_finished_in_elabftw(protocol, client):
await protocol.complete_next_step(123)
assert client.experiments[123].steps[0].finished is True
async def test_saga_journals_a_completed_operation(protocol, journal):
await protocol.complete_next_step(123)
# The journal must have recorded a completed operation for this run; the
# audit trail lives in the SQLite file (plan §10).
assert journal.db_path.exists()
assert journal.db_path.stat().st_size > 0
# --- step selection variants ------------------------------------------------
async def test_complete_with_explicit_step_id(protocol, client):
result = await protocol.complete_step(123, 10, comment="redo")
assert result.step.id == 10
assert result.step.finished is True
assert client.experiments[123].steps[1].finished is True
async def test_complete_next_skips_finished_steps(protocol, client):
client.experiments[123].steps[0].finished = True
result = await protocol.complete_next_step(123)
assert result.step.id == 10
# --- no-annotation steps ----------------------------------------------------
async def test_step_without_annotation_completes_and_posts_comment(protocol, client):
"""Plan §7.2: no annotation ⇒ complete step + comment, skip inventory."""
client.experiments[123].steps[0].body = "Plain step, no annotation"
result = await protocol.complete_next_step(123, comment="ok")
assert result.status == "completed"
assert result.consumed == []
assert result.comment_id is not None
assert client.calls_of("set_container_quantity") == []
# --- clarification before mutation ------------------------------------------
async def test_unresolvable_resource_key_aborts_without_mutation(protocol, client, resolver_stub):
resolver_stub.mappings.clear()
client.experiments[123].steps[0] = FakeStep(
id=9, body=f"Add mystery reagent\n{ANNOTATION_ETHANOL_FIFO}", ordering=1
)
with pytest.raises(ClarificationNeededError):
await protocol.complete_next_step(123)
assert client.calls_of("set_container_quantity") == []
assert client.calls_of("finish_step") == []
assert client.calls_of("post_comment") == []
assert client.experiments[123].steps[0].finished is False
async def test_insufficient_stock_aborts_without_mutation(protocol, client):
client.items[12].containers = [FakeContainer(sub_id=31, qty_stored=1.0, unit="mL")]
with pytest.raises(InsufficientStockError) as excinfo:
await protocol.complete_next_step(123)
assert "1.0" in str(excinfo.value)
assert client.calls_of("set_container_quantity") == []
assert client.calls_of("finish_step") == []
# --- optional consumables ---------------------------------------------------
async def test_optional_consumable_missing_stock_is_skipped_with_warning(protocol, client):
"""Plan §7.5: optional consumables are skipped with a warning, step completes."""
annotated = (
"<!-- labvoice:v1\n"
'{"consumables": ['
'{"resource_key": "ethanol_absolute", "quantity": 2.0, "unit": "mL"},'
'{"resource_key": "peanut_oil", "quantity": 1.0, "unit": "mL", "optional": true}'
"]}\n"
"-->"
)
client.experiments[123].steps[0] = FakeStep(id=9, body=f"Mix\n{annotated}", ordering=1)
result = await protocol.complete_next_step(123)
assert result.status == "completed"
assert [c.resource_key for c in result.consumed] == ["ethanol_absolute"]
# --- prompt_quantity --------------------------------------------------------
def _prompt_annotation_body() -> str:
return (
"<!-- labvoice:v1\n"
'{"prompt_quantity": true, "consumables": ['
'{"resource_key": "ethanol_absolute", "quantity": null, "unit": "mL"}'
"]}\n"
"-->"
)
async def test_prompt_quantity_without_quantities_asks_for_clarification(protocol, client):
client.experiments[123].steps[0] = FakeStep(
id=9, body=f"How much did you use?\n{_prompt_annotation_body()}", ordering=1
)
with pytest.raises(ClarificationNeededError) as excinfo:
await protocol.complete_next_step(123)
assert excinfo.value.options, "clarification should name the resources needing amounts"
assert client.calls_of("set_container_quantity") == []
async def test_prompt_quantity_with_supplied_quantities_completes(protocol, client):
client.experiments[123].steps[0] = FakeStep(
id=9, body=f"How much did you use?\n{_prompt_annotation_body()}", ordering=1
)
result = await protocol.complete_next_step(123, quantities={"ethanol_absolute": 5.0})
assert result.status == "completed"
assert result.consumed[0].amount == "5.0 mL"
assert client.items[12].containers[0].qty_stored == 45.0
# --- allocation behaviour in the saga ---------------------------------------
async def test_fifo_split_across_containers(protocol, client):
client.items[12].containers[0].qty_stored = 1.0
result = await protocol.complete_next_step(123)
assert [(c.container_id, c.amount) for c in result.consumed] == [
(31, "1.0 mL"),
(32, "1.0 mL"),
]
assert client.items[12].containers[0].qty_stored == 0.0
assert client.items[12].containers[1].qty_stored == 9.0
async def test_specific_allocation_uses_the_hinted_container(protocol, client):
annotated = (
"<!-- labvoice:v1\n"
'{"consumables": [{"resource_key": "ethanol_absolute", "quantity": 2.0,'
' "unit": "mL", "allocation": "specific", "container_id": 32}]}\n'
"-->"
)
client.experiments[123].steps[0] = FakeStep(id=9, body=f"x\n{annotated}", ordering=1)
result = await protocol.complete_next_step(123)
assert result.consumed[0].container_id == 32
assert client.items[12].containers[1].qty_stored == 8.0
async def test_unit_conversion_between_compatible_units(protocol, client):
"""Required 0.02 L (= 20 mL) against mL-stocked containers."""
annotated = (
"<!-- labvoice:v1\n"
'{"consumables": [{"resource_key": "ethanol_absolute", "quantity": 0.02,'
' "unit": "L"}]}\n'
"-->"
)
client.experiments[123].steps[0] = FakeStep(id=9, body=f"x\n{annotated}", ordering=1)
result = await protocol.complete_next_step(123)
assert client.items[12].containers[0].qty_stored == 30.0
assert result.consumed[0].amount == "20.0 mL"
# --- failure & compensation (plan §10) ---------------------------------------
async def test_failure_during_finish_step_returns_reverted_and_restores_stock(
protocol, client, monkeypatch
):
"""Plan §10: compensate by restoring decremented quantities, then `reverted`."""
async def fail_finish(entity_type, entity_id, step_id):
raise ApiError("eLabFTW is unhappy.")
monkeypatch.setattr(protocol.client, "finish_step", fail_finish)
result = await protocol.complete_next_step(123)
assert result.status == "reverted"
assert client.items[12].containers[0].qty_stored == 50.0
assert client.experiments[123].steps[0].finished is False
assert client.calls_of("post_comment") == []
async def test_comment_failure_after_finish_compensates_stock(protocol, client, monkeypatch):
async def fail_comment(entity_type, entity_id, body):
raise ApiError("comment refused.")
monkeypatch.setattr(protocol.client, "post_comment", fail_comment)
result = await protocol.complete_next_step(123)
assert result.status == "reverted"
assert client.items[12].containers[0].qty_stored == 50.0
async def test_failed_compensation_marks_partial_failure_and_posts_audit_comment(
protocol, client, monkeypatch
):
async def fail_finish(entity_type, entity_id, step_id):
raise ApiError("eLabFTW is unhappy.")
async def fail_restore(entity_type, entity_id, subitem_id, qty_stored):
raise ApiError("restore also failed.")
monkeypatch.setattr(protocol.client, "finish_step", fail_finish)
monkeypatch.setattr(protocol.client, "set_container_quantity", fail_restore)
result = await protocol.complete_next_step(123)
assert result is not None, "workflow must report partial_failure, not raise"
assert result.status == "partial_failure"
comments = client.experiments[123].comments
assert any("31" in c.body or "ethanol" in c.body.lower() for c in comments), (
"partial_failure must post an audit comment naming the inconsistent container"
)
# --- protocol_complete edge case (plan §7.1) ---------------------------------
async def test_complete_next_with_no_steps_left_returns_protocol_complete(protocol, client):
for step in client.experiments[123].steps:
step.finished = True
result = await protocol.complete_next_step(123)
assert result.status == "protocol_complete"
assert result.consumed == []
assert client.calls_of("set_container_quantity") == []
assert client.calls_of("post_comment") == []
# --- guard rails -------------------------------------------------------------
async def test_completing_an_already_finished_explicit_step_errors(protocol, client):
"""Plan §13 safety: immutable (already finished) step ⇒ explicit error."""
client.experiments[123].steps[0].finished = True
with pytest.raises(Exception) as excinfo:
await protocol.complete_step(123, 9)
assert not isinstance(excinfo.value, NotImplementedError), "scaffold not implemented yet"
async def test_read_only_key_surfaces_permission_error(protocol, client):
client.read_only = True
with pytest.raises(PermissionDeniedError):
await protocol.complete_next_step(123)
+135
View File
@@ -0,0 +1,135 @@
"""workflows/setup.py — scan_instance, map_resource, validate_protocol_template (plan §5)."""
import pytest
from fakes import FakeItem, make_item
# --- scan_instance ----------------------------------------------------------
async def test_scan_counts_containers_and_markers(setup, client):
client.items[13] = make_item(id=13, title="Water bottle", body="plain", containers=[])
result = await setup.scan_instance()
assert result.containers_seen == 2 # containers on item 12
assert result.markers_found == 1 # marker on item 12
async def test_scan_proposes_marker_matches(setup, client):
result = await setup.scan_instance()
proposal = next(p for p in result.proposals if p.resource_key == "ethanol_absolute")
assert proposal.item_id == 12
assert proposal.reason is not None
async def test_scan_proposes_exact_title_match_as_suggestion(setup, client):
"""Exact title matches are setup-time suggestions with scores (plan §5.4)."""
client.items[14] = FakeItem(id=14, title="sodium_azide", body="")
result = await setup.scan_instance()
proposal = next((p for p in result.proposals if p.item_id == 14), None)
assert proposal is not None
assert proposal.resource_key == "sodium_azide"
# --- map_resource -----------------------------------------------------------
async def test_map_resource_binds_key(setup, client, resolver_stub):
entry = await setup.map_resource("ethanol_absolute", 12)
assert entry.resource_key == "ethanol_absolute"
assert entry.item_id == 12
assert resolver_stub.mappings["ethanol_absolute"] == 12
async def test_map_resource_is_idempotent_upsert(setup, resolver_stub):
await setup.map_resource("ethanol_absolute", 12)
await setup.map_resource("ethanol_absolute", 12)
assert resolver_stub.mappings["ethanol_absolute"] == 12
entries = await resolver_stub.list_mappings()
assert len([e for e in entries if e.resource_key == "ethanol_absolute"]) == 1
async def test_map_resource_rebinding_updates(setup, resolver_stub):
await setup.map_resource("ethanol_absolute", 12)
await setup.map_resource("ethanol_absolute", 13)
assert resolver_stub.mappings["ethanol_absolute"] == 13
# --- validate_protocol_template ---------------------------------------------
def _annotated_template_client():
from fakes import FakeElabftwClient, FakeStep, make_experiment, make_item, make_template
annotated_step = FakeStep(
id=41,
body=(
"Add ethanol\n"
"<!-- labvoice:v1\n"
'{"consumables": [{"resource_key": "ethanol_absolute", "quantity": 2.0,'
' "unit": "mL"}]}\n'
"-->"
),
ordering=1,
)
broken_step = FakeStep(
id=42, body="Broken\n<!-- labvoice:v1\n{invalid json\n-->", ordering=2
)
templates = [
make_template(
id=7,
steps=[annotated_step, broken_step],
)
]
return FakeElabftwClient(
templates=templates, experiments=[make_experiment()], items=[make_item()]
)
async def test_validate_flags_unmapped_resource_key(setup, client, resolver_stub):
"""Annotation ok, but the mapping store lacks the key → blocking error."""
client.templates[7] = _annotated_template_client().templates[7]
resolver_stub.mappings.clear()
report = await setup.validate_protocol_template("experiments_templates", 7)
assert report.ok is False
errors = [i for i in report.issues if i.severity == "error"]
assert any(i.resource_key == "ethanol_absolute" for i in errors)
async def test_validate_ok_when_annotation_and_mapping_are_fine(setup, client, resolver_stub):
client.templates[7] = _annotated_template_client().templates[7]
report = await setup.validate_protocol_template("experiments_templates", 7)
assert report.ok is False # the broken json step must block
# fix the broken step, then expect a clean report
client.templates[7].steps[1].body = "Clean step without annotation"
report = await setup.validate_protocol_template("experiments_templates", 7)
assert report.ok is True
assert report.issues == []
async def test_validate_reports_annotation_error_with_step_id(setup, client):
client.templates[7] = _annotated_template_client().templates[7]
report = await setup.validate_protocol_template("experiments_templates", 7)
issues = [i for i in report.issues if i.step_id == 42]
assert issues and issues[0].severity == "error"
async def test_validate_entity_type_is_mirrored_in_report(setup, client):
client.templates[7] = _annotated_template_client().templates[7]
report = await setup.validate_protocol_template("experiments_templates", 7)
assert report.entity_type == "experiments_templates"
assert report.entity_id == 7
async def test_validate_experiment_steps_too(setup, client, resolver_stub):
"""validate works on live experiments, not only templates."""
resolver_stub.mappings.clear()
report = await setup.validate_protocol_template("experiments", 123)
assert report.entity_type == "experiments"
assert report.ok is False
async def test_validate_unknown_entity_fails_cleanly(setup):
with pytest.raises(Exception) as excinfo:
await setup.validate_protocol_template("experiments", 9999)
assert not isinstance(excinfo.value, NotImplementedError)
Generated
+1563
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
Metadata-Version: 2.4
Name: elabapi-python
Version: 5.6.1
Summary: eLabFTW REST API v2 Documentation
Home-page: https://github.com/elabftw/elabapi-python
Author-email:
Keywords: Swagger,eLabFTW REST API v2 Documentation
Requires-Dist: urllib3>=1.15
Requires-Dist: six>=1.10
Requires-Dist: certifi
Requires-Dist: python-dateutil
Dynamic: description
Dynamic: home-page
Dynamic: keywords
Dynamic: requires-dist
Dynamic: summary
This document describes all available endpoints and methods for eLabFTW&#x27;s API version 2. # noqa: E501
+417
View File
@@ -0,0 +1,417 @@
# elabapi-python
This document describes all available endpoints and methods for eLabFTW's API version 2.
This Python package is automatically generated by the [Swagger Codegen](https://github.com/swagger-api/swagger-codegen) project:
- API version: 2.0.0
- Package version: 5.6.1
- Build package: io.swagger.codegen.v3.generators.python.PythonClientCodegen
## Requirements.
Python 2.7 and 3.4+
## Installation & Usage
### pip install
If the python package is hosted on Github, you can install directly from Github
```sh
pip install git+https://github.com/elabftw/elabapi-python.git
```
(you may need to run `pip` with root permission: `sudo pip install git+https://github.com/elabftw/elabapi-python.git`)
Then import the package:
```python
import elabapi_python
```
### Setuptools
Install via [Setuptools](http://pypi.python.org/pypi/setuptools).
```sh
python setup.py install --user
```
(or `sudo python setup.py install` to install the package for all users)
Then import the package:
```python
import elabapi_python
```
## Getting Started
Please follow the [installation procedure](#installation--usage) and then run the following:
```python
from __future__ import print_function
import time
import elabapi_python
from elabapi_python.rest import ApiException
from pprint import pprint
# Configure API key authorization: token
configuration = elabapi_python.Configuration()
configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# configuration.api_key_prefix['Authorization'] = 'Bearer'
# create an instance of the API class
api_instance = elabapi_python.ApiKeysApi(elabapi_python.ApiClient(configuration))
id = 56 # int | ID of the API key
try:
# Delete an API key.
api_instance.delete_apikey(id)
except ApiException as e:
print("Exception when calling ApiKeysApi->delete_apikey: %s\n" % e)
# Configure API key authorization: token
configuration = elabapi_python.Configuration()
configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# configuration.api_key_prefix['Authorization'] = 'Bearer'
# create an instance of the API class
api_instance = elabapi_python.ApiKeysApi(elabapi_python.ApiClient(configuration))
try:
# Read API keys
api_response = api_instance.get_apikeys()
pprint(api_response)
except ApiException as e:
print("Exception when calling ApiKeysApi->get_apikeys: %s\n" % e)
# Configure API key authorization: token
configuration = elabapi_python.Configuration()
configuration.api_key['Authorization'] = 'YOUR_API_KEY'
# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
# configuration.api_key_prefix['Authorization'] = 'Bearer'
# create an instance of the API class
api_instance = elabapi_python.ApiKeysApi(elabapi_python.ApiClient(configuration))
body = elabapi_python.ApikeysBody() # ApikeysBody | (optional)
try:
# Create an API key
api_instance.post_apikeys(body=body)
except ApiException as e:
print("Exception when calling ApiKeysApi->post_apikeys: %s\n" % e)
```
## Documentation for API Endpoints
All URIs are relative to *https://elab.local:3148/api/v2*
Class | Method | HTTP request | Description
------------ | ------------- | ------------- | -------------
*ApiKeysApi* | [**delete_apikey**](docs/ApiKeysApi.md#delete_apikey) | **DELETE** /apikeys/{id} | Delete an API key.
*ApiKeysApi* | [**get_apikeys**](docs/ApiKeysApi.md#get_apikeys) | **GET** /apikeys | Read API keys
*ApiKeysApi* | [**post_apikeys**](docs/ApiKeysApi.md#post_apikeys) | **POST** /apikeys | Create an API key
*BrandingApi* | [**get_instance_branding**](docs/BrandingApi.md#get_instance_branding) | **GET** /instance/branding/{id} | Read a branding asset as a binary file.
*BrandingApi* | [**post_instance_branding**](docs/BrandingApi.md#post_instance_branding) | **POST** /instance/branding/{id} | Update a branding asset.
*CommentsApi* | [**delete_entity_comment**](docs/CommentsApi.md#delete_entity_comment) | **DELETE** /{entity_type}/{id}/comments/{subid} | Delete an entity comment.
*CommentsApi* | [**patch_entity_comment**](docs/CommentsApi.md#patch_entity_comment) | **PATCH** /{entity_type}/{id}/comments/{subid} | Modify an entity comment.
*CommentsApi* | [**post_entity_comments**](docs/CommentsApi.md#post_entity_comments) | **POST** /{entity_type}/{id}/comments | Create a comment.
*CommentsApi* | [**read_entity_comment**](docs/CommentsApi.md#read_entity_comment) | **GET** /{entity_type}/{id}/comments/{subid} | Read a comment of that entity.
*CommentsApi* | [**read_entity_comments**](docs/CommentsApi.md#read_entity_comments) | **GET** /{entity_type}/{id}/comments | Read all comments of that entity.
*CompoundsApi* | [**delete_compound**](docs/CompoundsApi.md#delete_compound) | **DELETE** /compounds/{id} | Soft-delete a compound
*CompoundsApi* | [**get_compound**](docs/CompoundsApi.md#get_compound) | **GET** /compounds/{id} | Get a single compound
*CompoundsApi* | [**get_compounds**](docs/CompoundsApi.md#get_compounds) | **GET** /compounds | Read compounds
*CompoundsApi* | [**post_compounds**](docs/CompoundsApi.md#post_compounds) | **POST** /compounds | Create or import a compound
*ConfigApi* | [**delete_config**](docs/ConfigApi.md#delete_config) | **DELETE** /config | Reset the config to default values
*ConfigApi* | [**get_config**](docs/ConfigApi.md#get_config) | **GET** /config | Read the config
*ConfigApi* | [**patch_config**](docs/ConfigApi.md#patch_config) | **PATCH** /config | Modify the config
*ContainersApi* | [**delete_container**](docs/ContainersApi.md#delete_container) | **DELETE** /{entity_type}/{id}/containers/{subid} | Delete a container.
*ContainersApi* | [**patch_container**](docs/ContainersApi.md#patch_container) | **PATCH** /{entity_type}/{id}/containers/{subid} | Modify a container.
*ContainersApi* | [**post_container**](docs/ContainersApi.md#post_container) | **POST** /{entity_type}/{id}/containers/{subid} | Create a container for this entity.
*ContainersApi* | [**read_container**](docs/ContainersApi.md#read_container) | **GET** /{entity_type}/{id}/containers/{subid} | Read a container.
*ContainersApi* | [**read_containers**](docs/ContainersApi.md#read_containers) | **GET** /{entity_type}/{id}/containers | Read all containers for this entity.
*CustomFieldsKeysApi* | [**custom_fields_keys**](docs/CustomFieldsKeysApi.md#custom_fields_keys) | **GET** /extra_fields_keys | Read custom fields keys.
*DSpaceApi* | [**dspace_create**](docs/DSpaceApi.md#dspace_create) | **POST** /dspace | Create a new DSpace workspace item.
*DSpaceApi* | [**dspace_read**](docs/DSpaceApi.md#dspace_read) | **GET** /dspace | Read DSpace information.
*DSpaceApi* | [**dspace_submit**](docs/DSpaceApi.md#dspace_submit) | **PATCH** /dspace | Submit an eLabFTW entry to DSpace.
*EventsApi* | [**delete_event**](docs/EventsApi.md#delete_event) | **DELETE** /event/{id} | Delete a booking slot.
*EventsApi* | [**patch_event**](docs/EventsApi.md#patch_event) | **PATCH** /event/{id} | Modify a booking slot.
*EventsApi* | [**post_events**](docs/EventsApi.md#post_events) | **POST** /events/{id} | Create an event for the item specified as id.
*EventsApi* | [**read_event**](docs/EventsApi.md#read_event) | **GET** /event/{id} | Read a booking slot.
*EventsApi* | [**read_events**](docs/EventsApi.md#read_events) | **GET** /events | Read all events in the team.
*ExperimentsApi* | [**delete_experiment**](docs/ExperimentsApi.md#delete_experiment) | **DELETE** /experiments/{id} | Delete an experiment.
*ExperimentsApi* | [**get_experiment**](docs/ExperimentsApi.md#get_experiment) | **GET** /experiments/{id} | Read an experiment
*ExperimentsApi* | [**patch_experiment**](docs/ExperimentsApi.md#patch_experiment) | **PATCH** /experiments/{id} | Modify an experiment
*ExperimentsApi* | [**post_experiment**](docs/ExperimentsApi.md#post_experiment) | **POST** /experiments | Create an experiment
*ExperimentsApi* | [**post_experiment_by_id**](docs/ExperimentsApi.md#post_experiment_by_id) | **POST** /experiments/{id} | Duplicate an experiment with its ID
*ExperimentsApi* | [**read_experiments**](docs/ExperimentsApi.md#read_experiments) | **GET** /experiments | Read all experiments that are accessible
*ExperimentsCategoriesApi* | [**delete_expcat**](docs/ExperimentsCategoriesApi.md#delete_expcat) | **DELETE** /teams/{id}/experiments_categories/{subid} | Delete a category.
*ExperimentsCategoriesApi* | [**patch_expcat**](docs/ExperimentsCategoriesApi.md#patch_expcat) | **PATCH** /teams/{id}/experiments_categories/{subid} | Modify a category.
*ExperimentsCategoriesApi* | [**post_team_one_expcat**](docs/ExperimentsCategoriesApi.md#post_team_one_expcat) | **POST** /teams/{id}/experiments_categories | Create a new category for experiments.
*ExperimentsCategoriesApi* | [**read_team_experiments_categories**](docs/ExperimentsCategoriesApi.md#read_team_experiments_categories) | **GET** /teams/{id}/experiments_categories | Read experiments categories of a team.
*ExperimentsCategoriesApi* | [**read_team_one_expcat**](docs/ExperimentsCategoriesApi.md#read_team_one_expcat) | **GET** /teams/{id}/experiments_categories/{subid} | Read a category.
*ExperimentsStatusApi* | [**delete_expstatus**](docs/ExperimentsStatusApi.md#delete_expstatus) | **DELETE** /teams/{id}/experiments_status/{subid} | Delete a status.
*ExperimentsStatusApi* | [**patch_expstatus**](docs/ExperimentsStatusApi.md#patch_expstatus) | **PATCH** /teams/{id}/experiments_status/{subid} | Modify a status.
*ExperimentsStatusApi* | [**post_team_one_expstatus**](docs/ExperimentsStatusApi.md#post_team_one_expstatus) | **POST** /teams/{id}/experiments_status | Create a new experiments status.
*ExperimentsStatusApi* | [**read_team_experiments_status**](docs/ExperimentsStatusApi.md#read_team_experiments_status) | **GET** /teams/{id}/experiments_status | Read experiments status of a team.
*ExperimentsStatusApi* | [**read_team_one_expstatus**](docs/ExperimentsStatusApi.md#read_team_one_expstatus) | **GET** /teams/{id}/experiments_status/{subid} | Read a status.
*ExperimentsTemplatesApi* | [**delete_experiment_template**](docs/ExperimentsTemplatesApi.md#delete_experiment_template) | **DELETE** /experiments_templates/{id} | Delete an experiment template.
*ExperimentsTemplatesApi* | [**get_experiment_template**](docs/ExperimentsTemplatesApi.md#get_experiment_template) | **GET** /experiments_templates/{id} | Read an experiment template
*ExperimentsTemplatesApi* | [**patch_experiment_template**](docs/ExperimentsTemplatesApi.md#patch_experiment_template) | **PATCH** /experiments_templates/{id} | Modify an experiment template
*ExperimentsTemplatesApi* | [**post_experiment_template**](docs/ExperimentsTemplatesApi.md#post_experiment_template) | **POST** /experiments_templates | Create an experiment template
*ExperimentsTemplatesApi* | [**post_experiment_template_by_id**](docs/ExperimentsTemplatesApi.md#post_experiment_template_by_id) | **POST** /experiments_templates/{id} | Duplicate an experiment template with its ID
*ExperimentsTemplatesApi* | [**read_experiments_templates**](docs/ExperimentsTemplatesApi.md#read_experiments_templates) | **GET** /experiments_templates | Read all experiments_templates that are accessible
*ExportsApi* | [**delete_export**](docs/ExportsApi.md#delete_export) | **DELETE** /exports/{id} | Remove an export request and its associated file
*ExportsApi* | [**post_export**](docs/ExportsApi.md#post_export) | **POST** /exports | Register an export request
*ExportsApi* | [**read_export**](docs/ExportsApi.md#read_export) | **GET** /exports/{id} | Get export
*ExportsApi* | [**readall_exports**](docs/ExportsApi.md#readall_exports) | **GET** /exports | List exports
*FavoriteTagsApi* | [**delete_favtag**](docs/FavoriteTagsApi.md#delete_favtag) | **DELETE** /favtags/{id} | Unfavorite a tag.
*FavoriteTagsApi* | [**post_favtags**](docs/FavoriteTagsApi.md#post_favtags) | **POST** /favtags | Add a tag as favorite.
*FavoriteTagsApi* | [**read_favtags**](docs/FavoriteTagsApi.md#read_favtags) | **GET** /favtags | Read all favorite tags for the user.
*IdpsApi* | [**delete_idp**](docs/IdpsApi.md#delete_idp) | **DELETE** /idps/{id} | Delete an idp.
*IdpsApi* | [**delete_idp_cert**](docs/IdpsApi.md#delete_idp_cert) | **DELETE** /idps/{id}/certs/{cert_id} | Delete an idp cert.
*IdpsApi* | [**delete_idp_endpoint**](docs/IdpsApi.md#delete_idp_endpoint) | **DELETE** /idps/{id}/endpoints/{endpoint_id} | Delete an idp endpoint.
*IdpsApi* | [**patch_idp**](docs/IdpsApi.md#patch_idp) | **PATCH** /idps/{id} | Actions on an idp.
*IdpsApi* | [**post_idp**](docs/IdpsApi.md#post_idp) | **POST** /idps | Create an idp.
*IdpsApi* | [**post_idp_cert**](docs/IdpsApi.md#post_idp_cert) | **POST** /idps/{id}/certs | Add a cert to this IdP.
*IdpsApi* | [**post_idp_endpoint**](docs/IdpsApi.md#post_idp_endpoint) | **POST** /idps/{id}/endpoints | Add an endpoint to this IdP.
*IdpsApi* | [**read_idp**](docs/IdpsApi.md#read_idp) | **GET** /idps/{id} | Read an idp.
*IdpsApi* | [**read_idp_cert**](docs/IdpsApi.md#read_idp_cert) | **GET** /idps/{id}/certs/{cert_id} | Read a cert for an IdP.
*IdpsApi* | [**read_idp_certs**](docs/IdpsApi.md#read_idp_certs) | **GET** /idps/{id}/certs | Read all certs for an IdP.
*IdpsApi* | [**read_idp_endpoint**](docs/IdpsApi.md#read_idp_endpoint) | **GET** /idps/{id}/endpoints/{endpoint_id} | Read an endpoint for an IdP.
*IdpsApi* | [**read_idp_endpoints**](docs/IdpsApi.md#read_idp_endpoints) | **GET** /idps/{id}/endpoints | Read all endpoints for an IdP.
*IdpsApi* | [**read_idps**](docs/IdpsApi.md#read_idps) | **GET** /idps | Read all IDPs.
*IdpsSourcesApi* | [**delete_idps_source**](docs/IdpsSourcesApi.md#delete_idps_source) | **DELETE** /idps_sources/{id} | Delete an idp source.
*IdpsSourcesApi* | [**patch_idps_source**](docs/IdpsSourcesApi.md#patch_idps_source) | **PATCH** /idps_sources/{id} | Patch an idp source.
*IdpsSourcesApi* | [**post_idp_source**](docs/IdpsSourcesApi.md#post_idp_source) | **POST** /idps_sources | Create an idp source.
*IdpsSourcesApi* | [**read_idps_source**](docs/IdpsSourcesApi.md#read_idps_source) | **GET** /idps_sources/{id} | Read an IdPs source
*IdpsSourcesApi* | [**read_idps_sources**](docs/IdpsSourcesApi.md#read_idps_sources) | **GET** /idps_sources | Read all IDPs sources
*ImportApi* | [**post_import**](docs/ImportApi.md#post_import) | **POST** /import | Send a file to import
*ImportApi* | [**read_import**](docs/ImportApi.md#read_import) | **GET** /import | Get information about this endpoint
*InfoApi* | [**get_info**](docs/InfoApi.md#get_info) | **GET** /info | Get information about the instance.
*InstanceApi* | [**post_instance**](docs/InstanceApi.md#post_instance) | **POST** /instance | Run an instance level action.
*ItemsApi* | [**delete_item**](docs/ItemsApi.md#delete_item) | **DELETE** /items/{id} | Delete an item.
*ItemsApi* | [**get_item**](docs/ItemsApi.md#get_item) | **GET** /items/{id} | Read an item
*ItemsApi* | [**patch_item**](docs/ItemsApi.md#patch_item) | **PATCH** /items/{id} | Modify an item
*ItemsApi* | [**post_item**](docs/ItemsApi.md#post_item) | **POST** /items | Create an item
*ItemsApi* | [**post_item_by_id**](docs/ItemsApi.md#post_item_by_id) | **POST** /items/{id} | Duplicate an item with its ID
*ItemsApi* | [**read_items**](docs/ItemsApi.md#read_items) | **GET** /items | Read all items that are accessible
*ItemsTypesResourcesTemplatesApi* | [**delete_items_type**](docs/ItemsTypesResourcesTemplatesApi.md#delete_items_type) | **DELETE** /items_types/{id} | Delete a resource template.
*ItemsTypesResourcesTemplatesApi* | [**get_items_type**](docs/ItemsTypesResourcesTemplatesApi.md#get_items_type) | **GET** /items_types/{id} | Read a resource template
*ItemsTypesResourcesTemplatesApi* | [**patch_items_type**](docs/ItemsTypesResourcesTemplatesApi.md#patch_items_type) | **PATCH** /items_types/{id} | Modify a resource template
*ItemsTypesResourcesTemplatesApi* | [**post_items_types**](docs/ItemsTypesResourcesTemplatesApi.md#post_items_types) | **POST** /items_types | Create a resource template
*ItemsTypesResourcesTemplatesApi* | [**post_items_types_by_id**](docs/ItemsTypesResourcesTemplatesApi.md#post_items_types_by_id) | **POST** /items_types/{id} | Duplicate a resource template with its ID
*ItemsTypesResourcesTemplatesApi* | [**read_items_types**](docs/ItemsTypesResourcesTemplatesApi.md#read_items_types) | **GET** /items_types | Read all resources templates that are accessible
*LinksToCompoundsApi* | [**delete_entity_compounds_link**](docs/LinksToCompoundsApi.md#delete_entity_compounds_link) | **DELETE** /{entity_type}/{id}/compounds_links/{subid} | Delete a compound link.
*LinksToCompoundsApi* | [**post_entity_compounds_links**](docs/LinksToCompoundsApi.md#post_entity_compounds_links) | **POST** /{entity_type}/{id}/compounds_links/{subid} | Link an entity to a compound.
*LinksToCompoundsApi* | [**read_entity_compounds_links**](docs/LinksToCompoundsApi.md#read_entity_compounds_links) | **GET** /{entity_type}/{id}/compounds_links | Read all compounds links of that entity.
*LinksToExperimentsApi* | [**delete_entity_experiments_link**](docs/LinksToExperimentsApi.md#delete_entity_experiments_link) | **DELETE** /{entity_type}/{id}/experiments_links/{subid} | Delete an experiment link.
*LinksToExperimentsApi* | [**post_entity_experiments_links**](docs/LinksToExperimentsApi.md#post_entity_experiments_links) | **POST** /{entity_type}/{id}/experiments_links/{subid} | Create or import a link.
*LinksToExperimentsApi* | [**read_entity_experiments_links**](docs/LinksToExperimentsApi.md#read_entity_experiments_links) | **GET** /{entity_type}/{id}/experiments_links | Read all experiments links of that entity.
*LinksToItemsApi* | [**delete_entitiy_items_link**](docs/LinksToItemsApi.md#delete_entitiy_items_link) | **DELETE** /{entity_type}/{id}/items_links/{subid} | Delete an item link.
*LinksToItemsApi* | [**post_entity_items_links**](docs/LinksToItemsApi.md#post_entity_items_links) | **POST** /{entity_type}/{id}/items_links/{subid} | Create or import a link.
*LinksToItemsApi* | [**read_entity_items_links**](docs/LinksToItemsApi.md#read_entity_items_links) | **GET** /{entity_type}/{id}/items_links | Read all items links of that entity.
*NotificationsApi* | [**delete_notifications**](docs/NotificationsApi.md#delete_notifications) | **DELETE** /users/{id}/notifications | Delete all notifications of the user.
*NotificationsApi* | [**patch_notification**](docs/NotificationsApi.md#patch_notification) | **PATCH** /users/{id}/notifications/{subid} | Update the acknowledgment status of a notification.
*NotificationsApi* | [**read_notification**](docs/NotificationsApi.md#read_notification) | **GET** /users/{id}/notifications/{subid} | Read a notification.
*NotificationsApi* | [**read_notifications**](docs/NotificationsApi.md#read_notifications) | **GET** /users/{id}/notifications | Read notifications of a user.
*ReportsApi* | [**get_reports**](docs/ReportsApi.md#get_reports) | **GET** /reports | Get usage reports
*ResourcesCategoriesApi* | [**delete_rescat**](docs/ResourcesCategoriesApi.md#delete_rescat) | **DELETE** /teams/{id}/resources_categories/{subid} | Delete a category.
*ResourcesCategoriesApi* | [**patch_rescat**](docs/ResourcesCategoriesApi.md#patch_rescat) | **PATCH** /teams/{id}/resources_categories/{subid} | Modify a category.
*ResourcesCategoriesApi* | [**post_team_one_rescat**](docs/ResourcesCategoriesApi.md#post_team_one_rescat) | **POST** /teams/{id}/resources_categories | Create a new category for resources.
*ResourcesCategoriesApi* | [**read_team_one_rescat**](docs/ResourcesCategoriesApi.md#read_team_one_rescat) | **GET** /teams/{id}/resources_categories/{subid} | Read a category.
*ResourcesCategoriesApi* | [**read_team_resources_categories**](docs/ResourcesCategoriesApi.md#read_team_resources_categories) | **GET** /teams/{id}/resources_categories | Read resources categories of a team.
*ResourcesStatusApi* | [**delete_resstatus**](docs/ResourcesStatusApi.md#delete_resstatus) | **DELETE** /teams/{id}/items_status/{subid} | Delete a status.
*ResourcesStatusApi* | [**patch_resstatus**](docs/ResourcesStatusApi.md#patch_resstatus) | **PATCH** /teams/{id}/items_status/{subid} | Modify a status.
*ResourcesStatusApi* | [**post_team_one_resstat**](docs/ResourcesStatusApi.md#post_team_one_resstat) | **POST** /teams/{id}/items_status | Create a new status for resources.
*ResourcesStatusApi* | [**read_team_items_status**](docs/ResourcesStatusApi.md#read_team_items_status) | **GET** /teams/{id}/items_status | Read resources status of a team.
*ResourcesStatusApi* | [**read_team_one_resstatus**](docs/ResourcesStatusApi.md#read_team_one_resstatus) | **GET** /teams/{id}/items_status/{subid} | Read a status.
*RevisionsApi* | [**patch_entity_revision**](docs/RevisionsApi.md#patch_entity_revision) | **PATCH** /{entity_type}/{id}/revisions/{subid} | Restore a revision.
*RevisionsApi* | [**read_entity_revision**](docs/RevisionsApi.md#read_entity_revision) | **GET** /{entity_type}/{id}/revisions/{subid} | Read a revision of that entity.
*RevisionsApi* | [**read_entity_revisions**](docs/RevisionsApi.md#read_entity_revisions) | **GET** /{entity_type}/{id}/revisions | Read all revisions of that entity.
*StepsApi* | [**delete_step**](docs/StepsApi.md#delete_step) | **DELETE** /{entity_type}/{id}/steps/{subid} | Delete a step.
*StepsApi* | [**patch_step**](docs/StepsApi.md#patch_step) | **PATCH** /{entity_type}/{id}/steps/{subid} | Actions on a step.
*StepsApi* | [**post_step**](docs/StepsApi.md#post_step) | **POST** /{entity_type}/{id}/steps | Create a step.
*StepsApi* | [**read_steps**](docs/StepsApi.md#read_steps) | **GET** /{entity_type}/{id}/steps | Read all steps of that entity.
*StorageUnitsApi* | [**delete_storage_unit**](docs/StorageUnitsApi.md#delete_storage_unit) | **DELETE** /storage_units/{id} | Delete a storage unit.
*StorageUnitsApi* | [**patch_storage_unit**](docs/StorageUnitsApi.md#patch_storage_unit) | **PATCH** /storage_units/{id} | Modify a storage unit.
*StorageUnitsApi* | [**post_storage_unit**](docs/StorageUnitsApi.md#post_storage_unit) | **POST** /storage_units | Create a new storage unit.
*StorageUnitsApi* | [**read_storage_unit**](docs/StorageUnitsApi.md#read_storage_unit) | **GET** /storage_units/{id} | Read a storage unit.
*StorageUnitsApi* | [**read_storage_units**](docs/StorageUnitsApi.md#read_storage_units) | **GET** /storage_units | Read all containers, or the storage unit hierarchy when &#x60;?hierarchy&#x3D;true&#x60;.
*TagsApi* | [**delete_tag**](docs/TagsApi.md#delete_tag) | **DELETE** /{entity_type}/{id}/tags | Delete all tags.
*TagsApi* | [**patch_tag**](docs/TagsApi.md#patch_tag) | **PATCH** /{entity_type}/{id}/tags/{subid} | Actions on a tag (like removing it from the entity).
*TagsApi* | [**post_tag**](docs/TagsApi.md#post_tag) | **POST** /{entity_type}/{id}/tags | Create a tag.
*TagsApi* | [**read_tag**](docs/TagsApi.md#read_tag) | **GET** /{entity_type}/{id}/tags/{subid} | Read a tag.
*TagsApi* | [**read_tags**](docs/TagsApi.md#read_tags) | **GET** /{entity_type}/{id}/tags | Read all tags of that entity.
*TeamTagsApi* | [**delete_team_tag**](docs/TeamTagsApi.md#delete_team_tag) | **DELETE** /teams/{id}/tags/{subid} | Delete a tag.
*TeamTagsApi* | [**patch_team_tag**](docs/TeamTagsApi.md#patch_team_tag) | **PATCH** /teams/{id}/tags/{subid} | Actions on a tag.
*TeamTagsApi* | [**post_team_tag**](docs/TeamTagsApi.md#post_team_tag) | **POST** /teams/{id}/tags | Create a tag in the team.
*TeamTagsApi* | [**read_team_tag**](docs/TeamTagsApi.md#read_team_tag) | **GET** /teams/{id}/tags/{subid} | Read a tag.
*TeamTagsApi* | [**read_team_tags**](docs/TeamTagsApi.md#read_team_tags) | **GET** /teams/{id}/tags | Read all tags for the team.
*TeamgroupsApi* | [**delete_teamgroup**](docs/TeamgroupsApi.md#delete_teamgroup) | **DELETE** /teams/{id}/teamgroups/{subid} | Delete a teamgroup.
*TeamgroupsApi* | [**patch_teamgroup**](docs/TeamgroupsApi.md#patch_teamgroup) | **PATCH** /teams/{id}/teamgroups/{subid} | Modify a teamgroup.
*TeamgroupsApi* | [**post_teamgroups**](docs/TeamgroupsApi.md#post_teamgroups) | **POST** /teams/{id}/teamgroups | Create a new teamgroup.
*TeamgroupsApi* | [**read_team_teamgroups**](docs/TeamgroupsApi.md#read_team_teamgroups) | **GET** /teams/{id}/teamgroups | Read teamgroups of a team.
*TeamgroupsApi* | [**read_teamgroup**](docs/TeamgroupsApi.md#read_teamgroup) | **GET** /teams/{id}/teamgroups/{subid} | Read a teamgroup.
*TeamsApi* | [**patch_team**](docs/TeamsApi.md#patch_team) | **PATCH** /teams/{id} | Actions on a team.
*TeamsApi* | [**post_teams**](docs/TeamsApi.md#post_teams) | **POST** /teams | Create a new team.
*TeamsApi* | [**read_team**](docs/TeamsApi.md#read_team) | **GET** /teams/{id} | Read a team. Requires Admin permissions.
*TeamsApi* | [**read_teams**](docs/TeamsApi.md#read_teams) | **GET** /teams | Read all teams. Requires Sysadmin permissions.
*TodolistApi* | [**delete_todoitem**](docs/TodolistApi.md#delete_todoitem) | **DELETE** /todolist/{id} | Delete a todoitem.
*TodolistApi* | [**patch_todoitem**](docs/TodolistApi.md#patch_todoitem) | **PATCH** /todolist/{id} | Actions on a todoitem.
*TodolistApi* | [**post_todolist**](docs/TodolistApi.md#post_todolist) | **POST** /todolist | Create a todo item
*TodolistApi* | [**read_todoitem**](docs/TodolistApi.md#read_todoitem) | **GET** /todolist/{id} | Read a todo entry.
*TodolistApi* | [**read_todolist**](docs/TodolistApi.md#read_todolist) | **GET** /todolist | Read all todoitems.
*UnfinishedStepsApi* | [**read_unfinished_steps**](docs/UnfinishedStepsApi.md#read_unfinished_steps) | **GET** /unfinished_steps | Read all unfinished steps.
*UploadsApi* | [**delete_upload**](docs/UploadsApi.md#delete_upload) | **DELETE** /{entity_type}/{id}/uploads/{subid} | Delete an upload.
*UploadsApi* | [**patch_upload**](docs/UploadsApi.md#patch_upload) | **PATCH** /{entity_type}/{id}/uploads/{subid} | Modify attributes such as \&quot;real_name\&quot;, \&quot;comment\&quot; or \&quot;state\&quot; of an upload.
*UploadsApi* | [**post_upload**](docs/UploadsApi.md#post_upload) | **POST** /{entity_type}/{id}/uploads | Create an upload.
*UploadsApi* | [**post_upload_replace**](docs/UploadsApi.md#post_upload_replace) | **POST** /{entity_type}/{id}/uploads/{subid} | Replace an existing uploaded file. The existing file will be archived and the new one will be added.
*UploadsApi* | [**read_upload**](docs/UploadsApi.md#read_upload) | **GET** /{entity_type}/{id}/uploads/{subid} | Read an upload.
*UploadsApi* | [**read_uploads**](docs/UploadsApi.md#read_uploads) | **GET** /{entity_type}/{id}/uploads | Read attached files of that entity.
*UserUploadsApi* | [**read_user_uploads**](docs/UserUploadsApi.md#read_user_uploads) | **GET** /users/{id}/uploads | Read attached files from a user
*UsersApi* | [**patch_user**](docs/UsersApi.md#patch_user) | **PATCH** /users/{id} | Modify a user.
*UsersApi* | [**post_user**](docs/UsersApi.md#post_user) | **POST** /users | Create a new user.
*UsersApi* | [**read_user**](docs/UsersApi.md#read_user) | **GET** /users/{id} | Read information of a user.
*UsersApi* | [**read_users**](docs/UsersApi.md#read_users) | **GET** /users | Read users from instance.
## Documentation For Models
- [AllOfentityEditableMetadatamerge](docs/AllOfentityEditableMetadatamerge.md)
- [AnyOfuploadsSubidBody1](docs/AnyOfuploadsSubidBody1.md)
- [Apikey](docs/Apikey.md)
- [ApikeysBody](docs/ApikeysBody.md)
- [BrandingIdBody](docs/BrandingIdBody.md)
- [Changelog](docs/Changelog.md)
- [Comment](docs/Comment.md)
- [Compound](docs/Compound.md)
- [CompoundCreateRequest](docs/CompoundCreateRequest.md)
- [CompoundDuplicateRequest](docs/CompoundDuplicateRequest.md)
- [CompoundsBody](docs/CompoundsBody.md)
- [CompoundsLinksSubidBody](docs/CompoundsLinksSubidBody.md)
- [Config](docs/Config.md)
- [Container](docs/Container.md)
- [ContainersSubidBody](docs/ContainersSubidBody.md)
- [ContainersSubidBody1](docs/ContainersSubidBody1.md)
- [DspaceBody](docs/DspaceBody.md)
- [DspaceBody1](docs/DspaceBody1.md)
- [DspaceEntity](docs/DspaceEntity.md)
- [DspaceMetadata](docs/DspaceMetadata.md)
- [DspaceMetadata1](docs/DspaceMetadata1.md)
- [Entity](docs/Entity.md)
- [EntityAndItemEditable](docs/EntityAndItemEditable.md)
- [EntityDuplicate](docs/EntityDuplicate.md)
- [EntityEditable](docs/EntityEditable.md)
- [EntityTemplate](docs/EntityTemplate.md)
- [EntityTemplateEditable](docs/EntityTemplateEditable.md)
- [Error](docs/Error.md)
- [Event](docs/Event.md)
- [EventIdBody](docs/EventIdBody.md)
- [EventidDelta](docs/EventidDelta.md)
- [EventsIdBody](docs/EventsIdBody.md)
- [ExclusiveEditMode](docs/ExclusiveEditMode.md)
- [Experiment](docs/Experiment.md)
- [ExperimentsLinksSubidBody](docs/ExperimentsLinksSubidBody.md)
- [ExperimentsTemplatesIdBody](docs/ExperimentsTemplatesIdBody.md)
- [Export](docs/Export.md)
- [ExportsBody](docs/ExportsBody.md)
- [ExtraFieldsKeys](docs/ExtraFieldsKeys.md)
- [FavtagsBody](docs/FavtagsBody.md)
- [Id](docs/Id.md)
- [Id1](docs/Id1.md)
- [Id2](docs/Id2.md)
- [Id3](docs/Id3.md)
- [Id4](docs/Id4.md)
- [IdCertsBody](docs/IdCertsBody.md)
- [IdCommentsBody](docs/IdCommentsBody.md)
- [IdEndpointsBody](docs/IdEndpointsBody.md)
- [IdExperimentsCategoriesBody](docs/IdExperimentsCategoriesBody.md)
- [IdExperimentsStatusBody](docs/IdExperimentsStatusBody.md)
- [IdItemsStatusBody](docs/IdItemsStatusBody.md)
- [IdResourcesCategoriesBody](docs/IdResourcesCategoriesBody.md)
- [IdStepsBody](docs/IdStepsBody.md)
- [IdTagsBody](docs/IdTagsBody.md)
- [IdTagsBody1](docs/IdTagsBody1.md)
- [IdTeamgroupsBody](docs/IdTeamgroupsBody.md)
- [IdUploadsBody](docs/IdUploadsBody.md)
- [Idp](docs/Idp.md)
- [IdpCert](docs/IdpCert.md)
- [IdpEndpoint](docs/IdpEndpoint.md)
- [IdpSource](docs/IdpSource.md)
- [IdpsBody](docs/IdpsBody.md)
- [IdpsIdBody](docs/IdpsIdBody.md)
- [IdpsSourcesBody](docs/IdpsSourcesBody.md)
- [IdpsSourcesIdBody](docs/IdpsSourcesIdBody.md)
- [ImportBody](docs/ImportBody.md)
- [InlineResponse200](docs/InlineResponse200.md)
- [InlineResponse2001](docs/InlineResponse2001.md)
- [InlineResponse20010](docs/InlineResponse20010.md)
- [InlineResponse2001Experiments](docs/InlineResponse2001Experiments.md)
- [InlineResponse2001Items](docs/InlineResponse2001Items.md)
- [InlineResponse2001Users](docs/InlineResponse2001Users.md)
- [InlineResponse2002](docs/InlineResponse2002.md)
- [InlineResponse2003](docs/InlineResponse2003.md)
- [InlineResponse2004](docs/InlineResponse2004.md)
- [InlineResponse2004QueryParameters](docs/InlineResponse2004QueryParameters.md)
- [InlineResponse2005](docs/InlineResponse2005.md)
- [InlineResponse2006](docs/InlineResponse2006.md)
- [InlineResponse2007](docs/InlineResponse2007.md)
- [InlineResponse2008](docs/InlineResponse2008.md)
- [InlineResponse2009](docs/InlineResponse2009.md)
- [InlineResponse2009Embedded](docs/InlineResponse2009Embedded.md)
- [InlineResponse2009EmbeddedEntries](docs/InlineResponse2009EmbeddedEntries.md)
- [InstanceBody](docs/InstanceBody.md)
- [Item](docs/Item.md)
- [ItemEditable](docs/ItemEditable.md)
- [ItemsLinksSubidBody](docs/ItemsLinksSubidBody.md)
- [ItemsTypesIdBody](docs/ItemsTypesIdBody.md)
- [Link](docs/Link.md)
- [Metadata](docs/Metadata.md)
- [MetadataElabftw](docs/MetadataElabftw.md)
- [MetadataElabftwExtraFieldsGroups](docs/MetadataElabftwExtraFieldsGroups.md)
- [MetadataExtraFields](docs/MetadataExtraFields.md)
- [Notification](docs/Notification.md)
- [NotificationsSubidBody](docs/NotificationsSubidBody.md)
- [PatchAction](docs/PatchAction.md)
- [Revision](docs/Revision.md)
- [RevisionsSubidBody](docs/RevisionsSubidBody.md)
- [Statuslike](docs/Statuslike.md)
- [Step](docs/Step.md)
- [StepsSubidBody](docs/StepsSubidBody.md)
- [StorageUnit](docs/StorageUnit.md)
- [StorageUnitsBody](docs/StorageUnitsBody.md)
- [StorageUnitsIdBody](docs/StorageUnitsIdBody.md)
- [Tag](docs/Tag.md)
- [TagsSubidBody](docs/TagsSubidBody.md)
- [TagsSubidBody1](docs/TagsSubidBody1.md)
- [Team](docs/Team.md)
- [Teamgroup](docs/Teamgroup.md)
- [TeamgroupUsers](docs/TeamgroupUsers.md)
- [TeamgroupsSubidBody](docs/TeamgroupsSubidBody.md)
- [TeamsBody](docs/TeamsBody.md)
- [TeamsIdBody](docs/TeamsIdBody.md)
- [Todoitem](docs/Todoitem.md)
- [TodolistBody](docs/TodolistBody.md)
- [TodolistIdBody](docs/TodolistIdBody.md)
- [UnfinishedStep](docs/UnfinishedStep.md)
- [UnfinishedSteps](docs/UnfinishedSteps.md)
- [Upload](docs/Upload.md)
- [UploadsSubidBody](docs/UploadsSubidBody.md)
- [UploadsSubidBody1](docs/UploadsSubidBody1.md)
- [Users](docs/Users.md)
- [UsersBody](docs/UsersBody.md)
- [UsersFull](docs/UsersFull.md)
- [UsersFullTeams](docs/UsersFullTeams.md)
- [UsersIdBody](docs/UsersIdBody.md)
## Documentation For Authorization
## token
- **Type**: API key
- **API key parameter name**: Authorization
- **Location**: HTTP header
## Author
+19
View File
@@ -0,0 +1,19 @@
Metadata-Version: 2.4
Name: elabapi-python
Version: 5.6.1
Summary: eLabFTW REST API v2 Documentation
Home-page: https://github.com/elabftw/elabapi-python
Author-email:
Keywords: Swagger,eLabFTW REST API v2 Documentation
Requires-Dist: urllib3>=1.15
Requires-Dist: six>=1.10
Requires-Dist: certifi
Requires-Dist: python-dateutil
Dynamic: description
Dynamic: home-page
Dynamic: keywords
Dynamic: requires-dist
Dynamic: summary
This document describes all available endpoints and methods for eLabFTW&#x27;s API version 2. # noqa: E501
@@ -0,0 +1,348 @@
README.md
setup.py
elabapi_python/__init__.py
elabapi_python/api_client.py
elabapi_python/configuration.py
elabapi_python/rest.py
elabapi_python.egg-info/PKG-INFO
elabapi_python.egg-info/SOURCES.txt
elabapi_python.egg-info/dependency_links.txt
elabapi_python.egg-info/requires.txt
elabapi_python.egg-info/top_level.txt
elabapi_python/api/__init__.py
elabapi_python/api/api_keys_api.py
elabapi_python/api/branding_api.py
elabapi_python/api/comments_api.py
elabapi_python/api/compounds_api.py
elabapi_python/api/config_api.py
elabapi_python/api/containers_api.py
elabapi_python/api/custom_fields_keys_api.py
elabapi_python/api/d_space_api.py
elabapi_python/api/events_api.py
elabapi_python/api/experiments_api.py
elabapi_python/api/experiments_categories_api.py
elabapi_python/api/experiments_status_api.py
elabapi_python/api/experiments_templates_api.py
elabapi_python/api/exports_api.py
elabapi_python/api/favorite_tags_api.py
elabapi_python/api/idps_api.py
elabapi_python/api/idps_sources_api.py
elabapi_python/api/import_api.py
elabapi_python/api/info_api.py
elabapi_python/api/instance_api.py
elabapi_python/api/items_api.py
elabapi_python/api/items_types__resources_templates_api.py
elabapi_python/api/links_to_compounds_api.py
elabapi_python/api/links_to_experiments_api.py
elabapi_python/api/links_to_items_api.py
elabapi_python/api/notifications_api.py
elabapi_python/api/reports_api.py
elabapi_python/api/resources_categories_api.py
elabapi_python/api/resources_status_api.py
elabapi_python/api/revisions_api.py
elabapi_python/api/steps_api.py
elabapi_python/api/storage_units_api.py
elabapi_python/api/tags_api.py
elabapi_python/api/team_tags_api.py
elabapi_python/api/teamgroups_api.py
elabapi_python/api/teams_api.py
elabapi_python/api/todolist_api.py
elabapi_python/api/unfinished_steps_api.py
elabapi_python/api/uploads_api.py
elabapi_python/api/user_uploads_api.py
elabapi_python/api/users_api.py
elabapi_python/models/__init__.py
elabapi_python/models/all_ofentity_editable_metadatamerge.py
elabapi_python/models/any_ofuploads_subid_body1.py
elabapi_python/models/apikey.py
elabapi_python/models/apikeys_body.py
elabapi_python/models/branding_id_body.py
elabapi_python/models/changelog.py
elabapi_python/models/comment.py
elabapi_python/models/compound.py
elabapi_python/models/compound_create_request.py
elabapi_python/models/compound_duplicate_request.py
elabapi_python/models/compounds_body.py
elabapi_python/models/compounds_links_subid_body.py
elabapi_python/models/config.py
elabapi_python/models/container.py
elabapi_python/models/containers_subid_body.py
elabapi_python/models/containers_subid_body1.py
elabapi_python/models/dspace_body.py
elabapi_python/models/dspace_body1.py
elabapi_python/models/dspace_entity.py
elabapi_python/models/dspace_metadata.py
elabapi_python/models/dspace_metadata1.py
elabapi_python/models/entity.py
elabapi_python/models/entity_and_item_editable.py
elabapi_python/models/entity_duplicate.py
elabapi_python/models/entity_editable.py
elabapi_python/models/entity_template.py
elabapi_python/models/entity_template_editable.py
elabapi_python/models/error.py
elabapi_python/models/event.py
elabapi_python/models/event_id_body.py
elabapi_python/models/eventid_delta.py
elabapi_python/models/events_id_body.py
elabapi_python/models/exclusive_edit_mode.py
elabapi_python/models/experiment.py
elabapi_python/models/experiments_links_subid_body.py
elabapi_python/models/experiments_templates_id_body.py
elabapi_python/models/export.py
elabapi_python/models/exports_body.py
elabapi_python/models/extra_fields_keys.py
elabapi_python/models/favtags_body.py
elabapi_python/models/id.py
elabapi_python/models/id1.py
elabapi_python/models/id2.py
elabapi_python/models/id3.py
elabapi_python/models/id4.py
elabapi_python/models/id_certs_body.py
elabapi_python/models/id_comments_body.py
elabapi_python/models/id_endpoints_body.py
elabapi_python/models/id_experiments_categories_body.py
elabapi_python/models/id_experiments_status_body.py
elabapi_python/models/id_items_status_body.py
elabapi_python/models/id_resources_categories_body.py
elabapi_python/models/id_steps_body.py
elabapi_python/models/id_tags_body.py
elabapi_python/models/id_tags_body1.py
elabapi_python/models/id_teamgroups_body.py
elabapi_python/models/id_uploads_body.py
elabapi_python/models/idp.py
elabapi_python/models/idp_cert.py
elabapi_python/models/idp_endpoint.py
elabapi_python/models/idp_source.py
elabapi_python/models/idps_body.py
elabapi_python/models/idps_id_body.py
elabapi_python/models/idps_sources_body.py
elabapi_python/models/idps_sources_id_body.py
elabapi_python/models/import_body.py
elabapi_python/models/inline_response200.py
elabapi_python/models/inline_response2001.py
elabapi_python/models/inline_response20010.py
elabapi_python/models/inline_response2001_experiments.py
elabapi_python/models/inline_response2001_items.py
elabapi_python/models/inline_response2001_users.py
elabapi_python/models/inline_response2002.py
elabapi_python/models/inline_response2003.py
elabapi_python/models/inline_response2004.py
elabapi_python/models/inline_response2004_query_parameters.py
elabapi_python/models/inline_response2005.py
elabapi_python/models/inline_response2006.py
elabapi_python/models/inline_response2007.py
elabapi_python/models/inline_response2008.py
elabapi_python/models/inline_response2009.py
elabapi_python/models/inline_response2009_embedded.py
elabapi_python/models/inline_response2009_embedded_entries.py
elabapi_python/models/instance_body.py
elabapi_python/models/item.py
elabapi_python/models/item_editable.py
elabapi_python/models/items_links_subid_body.py
elabapi_python/models/items_types_id_body.py
elabapi_python/models/link.py
elabapi_python/models/metadata.py
elabapi_python/models/metadata_elabftw.py
elabapi_python/models/metadata_elabftw_extra_fields_groups.py
elabapi_python/models/metadata_extra_fields.py
elabapi_python/models/notification.py
elabapi_python/models/notifications_subid_body.py
elabapi_python/models/patch_action.py
elabapi_python/models/revision.py
elabapi_python/models/revisions_subid_body.py
elabapi_python/models/statuslike.py
elabapi_python/models/step.py
elabapi_python/models/steps_subid_body.py
elabapi_python/models/storage_unit.py
elabapi_python/models/storage_units_body.py
elabapi_python/models/storage_units_id_body.py
elabapi_python/models/tag.py
elabapi_python/models/tags_subid_body.py
elabapi_python/models/tags_subid_body1.py
elabapi_python/models/team.py
elabapi_python/models/teamgroup.py
elabapi_python/models/teamgroup_users.py
elabapi_python/models/teamgroups_subid_body.py
elabapi_python/models/teams_body.py
elabapi_python/models/teams_id_body.py
elabapi_python/models/todoitem.py
elabapi_python/models/todolist_body.py
elabapi_python/models/todolist_id_body.py
elabapi_python/models/unfinished_step.py
elabapi_python/models/unfinished_steps.py
elabapi_python/models/upload.py
elabapi_python/models/uploads_subid_body.py
elabapi_python/models/uploads_subid_body1.py
elabapi_python/models/users.py
elabapi_python/models/users_body.py
elabapi_python/models/users_full.py
elabapi_python/models/users_full_teams.py
elabapi_python/models/users_id_body.py
test/__init__.py
test/test_all_ofentity_editable_metadatamerge.py
test/test_any_ofuploads_subid_body1.py
test/test_api_keys_api.py
test/test_apikey.py
test/test_apikeys_body.py
test/test_branding_api.py
test/test_branding_id_body.py
test/test_changelog.py
test/test_comment.py
test/test_comments_api.py
test/test_compound.py
test/test_compound_create_request.py
test/test_compound_duplicate_request.py
test/test_compounds_api.py
test/test_compounds_body.py
test/test_compounds_links_subid_body.py
test/test_config.py
test/test_config_api.py
test/test_container.py
test/test_containers_api.py
test/test_containers_subid_body.py
test/test_containers_subid_body1.py
test/test_custom_fields_keys_api.py
test/test_d_space_api.py
test/test_dspace_body.py
test/test_dspace_body1.py
test/test_dspace_entity.py
test/test_dspace_metadata.py
test/test_dspace_metadata1.py
test/test_entity.py
test/test_entity_and_item_editable.py
test/test_entity_duplicate.py
test/test_entity_editable.py
test/test_entity_template.py
test/test_entity_template_editable.py
test/test_error.py
test/test_event.py
test/test_event_id_body.py
test/test_eventid_delta.py
test/test_events_api.py
test/test_events_id_body.py
test/test_exclusive_edit_mode.py
test/test_experiment.py
test/test_experiments_api.py
test/test_experiments_categories_api.py
test/test_experiments_links_subid_body.py
test/test_experiments_status_api.py
test/test_experiments_templates_api.py
test/test_experiments_templates_id_body.py
test/test_export.py
test/test_exports_api.py
test/test_exports_body.py
test/test_extra_fields_keys.py
test/test_favorite_tags_api.py
test/test_favtags_body.py
test/test_id.py
test/test_id1.py
test/test_id2.py
test/test_id3.py
test/test_id4.py
test/test_id_certs_body.py
test/test_id_comments_body.py
test/test_id_endpoints_body.py
test/test_id_experiments_categories_body.py
test/test_id_experiments_status_body.py
test/test_id_items_status_body.py
test/test_id_resources_categories_body.py
test/test_id_steps_body.py
test/test_id_tags_body.py
test/test_id_tags_body1.py
test/test_id_teamgroups_body.py
test/test_id_uploads_body.py
test/test_idp.py
test/test_idp_cert.py
test/test_idp_endpoint.py
test/test_idp_source.py
test/test_idps_api.py
test/test_idps_body.py
test/test_idps_id_body.py
test/test_idps_sources_api.py
test/test_idps_sources_body.py
test/test_idps_sources_id_body.py
test/test_import_api.py
test/test_import_body.py
test/test_info_api.py
test/test_inline_response200.py
test/test_inline_response2001.py
test/test_inline_response20010.py
test/test_inline_response2001_experiments.py
test/test_inline_response2001_items.py
test/test_inline_response2001_users.py
test/test_inline_response2002.py
test/test_inline_response2003.py
test/test_inline_response2004.py
test/test_inline_response2004_query_parameters.py
test/test_inline_response2005.py
test/test_inline_response2006.py
test/test_inline_response2007.py
test/test_inline_response2008.py
test/test_inline_response2009.py
test/test_inline_response2009_embedded.py
test/test_inline_response2009_embedded_entries.py
test/test_instance_api.py
test/test_instance_body.py
test/test_item.py
test/test_item_editable.py
test/test_items_api.py
test/test_items_links_subid_body.py
test/test_items_types__resources_templates_api.py
test/test_items_types_id_body.py
test/test_link.py
test/test_links_to_compounds_api.py
test/test_links_to_experiments_api.py
test/test_links_to_items_api.py
test/test_metadata.py
test/test_metadata_elabftw.py
test/test_metadata_elabftw_extra_fields_groups.py
test/test_metadata_extra_fields.py
test/test_notification.py
test/test_notifications_api.py
test/test_notifications_subid_body.py
test/test_patch_action.py
test/test_reports_api.py
test/test_resources_categories_api.py
test/test_resources_status_api.py
test/test_revision.py
test/test_revisions_api.py
test/test_revisions_subid_body.py
test/test_statuslike.py
test/test_step.py
test/test_steps_api.py
test/test_steps_subid_body.py
test/test_storage_unit.py
test/test_storage_units_api.py
test/test_storage_units_body.py
test/test_storage_units_id_body.py
test/test_tag.py
test/test_tags_api.py
test/test_tags_subid_body.py
test/test_tags_subid_body1.py
test/test_team.py
test/test_team_tags_api.py
test/test_teamgroup.py
test/test_teamgroup_users.py
test/test_teamgroups_api.py
test/test_teamgroups_subid_body.py
test/test_teams_api.py
test/test_teams_body.py
test/test_teams_id_body.py
test/test_todoitem.py
test/test_todolist_api.py
test/test_todolist_body.py
test/test_todolist_id_body.py
test/test_unfinished_step.py
test/test_unfinished_steps.py
test/test_unfinished_steps_api.py
test/test_upload.py
test/test_uploads_api.py
test/test_uploads_subid_body.py
test/test_uploads_subid_body1.py
test/test_user_uploads_api.py
test/test_users.py
test/test_users_api.py
test/test_users_body.py
test/test_users_full.py
test/test_users_full_teams.py
test/test_users_id_body.py
@@ -0,0 +1 @@
@@ -0,0 +1,4 @@
urllib3>=1.15
six>=1.10
certifi
python-dateutil
@@ -0,0 +1,2 @@
elabapi_python
test
+188
View File
@@ -0,0 +1,188 @@
# coding: utf-8
# flake8: noqa
"""
eLabFTW REST API v2 Documentation
This document describes all available endpoints and methods for eLabFTW's API version 2. # noqa: E501
OpenAPI spec version: 2.0.0
Generated by: https://github.com/swagger-api/swagger-codegen.git
"""
from __future__ import absolute_import
# import apis into sdk package
from elabapi_python.api.api_keys_api import ApiKeysApi
from elabapi_python.api.branding_api import BrandingApi
from elabapi_python.api.comments_api import CommentsApi
from elabapi_python.api.compounds_api import CompoundsApi
from elabapi_python.api.config_api import ConfigApi
from elabapi_python.api.containers_api import ContainersApi
from elabapi_python.api.custom_fields_keys_api import CustomFieldsKeysApi
from elabapi_python.api.d_space_api import DSpaceApi
from elabapi_python.api.events_api import EventsApi
from elabapi_python.api.experiments_api import ExperimentsApi
from elabapi_python.api.experiments_categories_api import ExperimentsCategoriesApi
from elabapi_python.api.experiments_status_api import ExperimentsStatusApi
from elabapi_python.api.experiments_templates_api import ExperimentsTemplatesApi
from elabapi_python.api.exports_api import ExportsApi
from elabapi_python.api.favorite_tags_api import FavoriteTagsApi
from elabapi_python.api.idps_api import IdpsApi
from elabapi_python.api.idps_sources_api import IdpsSourcesApi
from elabapi_python.api.import_api import ImportApi
from elabapi_python.api.info_api import InfoApi
from elabapi_python.api.instance_api import InstanceApi
from elabapi_python.api.items_api import ItemsApi
from elabapi_python.api.items_types__resources_templates_api import ItemsTypesResourcesTemplatesApi
from elabapi_python.api.links_to_compounds_api import LinksToCompoundsApi
from elabapi_python.api.links_to_experiments_api import LinksToExperimentsApi
from elabapi_python.api.links_to_items_api import LinksToItemsApi
from elabapi_python.api.notifications_api import NotificationsApi
from elabapi_python.api.reports_api import ReportsApi
from elabapi_python.api.resources_categories_api import ResourcesCategoriesApi
from elabapi_python.api.resources_status_api import ResourcesStatusApi
from elabapi_python.api.revisions_api import RevisionsApi
from elabapi_python.api.steps_api import StepsApi
from elabapi_python.api.storage_units_api import StorageUnitsApi
from elabapi_python.api.tags_api import TagsApi
from elabapi_python.api.team_tags_api import TeamTagsApi
from elabapi_python.api.teamgroups_api import TeamgroupsApi
from elabapi_python.api.teams_api import TeamsApi
from elabapi_python.api.todolist_api import TodolistApi
from elabapi_python.api.unfinished_steps_api import UnfinishedStepsApi
from elabapi_python.api.uploads_api import UploadsApi
from elabapi_python.api.user_uploads_api import UserUploadsApi
from elabapi_python.api.users_api import UsersApi
# import ApiClient
from elabapi_python.api_client import ApiClient
from elabapi_python.configuration import Configuration
# import models into sdk package
from elabapi_python.models.all_ofentity_editable_metadatamerge import AllOfentityEditableMetadatamerge
from elabapi_python.models.any_ofuploads_subid_body1 import AnyOfuploadsSubidBody1
from elabapi_python.models.apikey import Apikey
from elabapi_python.models.apikeys_body import ApikeysBody
from elabapi_python.models.branding_id_body import BrandingIdBody
from elabapi_python.models.changelog import Changelog
from elabapi_python.models.comment import Comment
from elabapi_python.models.compound import Compound
from elabapi_python.models.compound_create_request import CompoundCreateRequest
from elabapi_python.models.compound_duplicate_request import CompoundDuplicateRequest
from elabapi_python.models.compounds_body import CompoundsBody
from elabapi_python.models.compounds_links_subid_body import CompoundsLinksSubidBody
from elabapi_python.models.config import Config
from elabapi_python.models.container import Container
from elabapi_python.models.containers_subid_body import ContainersSubidBody
from elabapi_python.models.containers_subid_body1 import ContainersSubidBody1
from elabapi_python.models.dspace_body import DspaceBody
from elabapi_python.models.dspace_body1 import DspaceBody1
from elabapi_python.models.dspace_entity import DspaceEntity
from elabapi_python.models.dspace_metadata import DspaceMetadata
from elabapi_python.models.dspace_metadata1 import DspaceMetadata1
from elabapi_python.models.entity import Entity
from elabapi_python.models.entity_and_item_editable import EntityAndItemEditable
from elabapi_python.models.entity_duplicate import EntityDuplicate
from elabapi_python.models.entity_editable import EntityEditable
from elabapi_python.models.entity_template import EntityTemplate
from elabapi_python.models.entity_template_editable import EntityTemplateEditable
from elabapi_python.models.error import Error
from elabapi_python.models.event import Event
from elabapi_python.models.event_id_body import EventIdBody
from elabapi_python.models.eventid_delta import EventidDelta
from elabapi_python.models.events_id_body import EventsIdBody
from elabapi_python.models.exclusive_edit_mode import ExclusiveEditMode
from elabapi_python.models.experiment import Experiment
from elabapi_python.models.experiments_links_subid_body import ExperimentsLinksSubidBody
from elabapi_python.models.experiments_templates_id_body import ExperimentsTemplatesIdBody
from elabapi_python.models.export import Export
from elabapi_python.models.exports_body import ExportsBody
from elabapi_python.models.extra_fields_keys import ExtraFieldsKeys
from elabapi_python.models.favtags_body import FavtagsBody
from elabapi_python.models.id import Id
from elabapi_python.models.id1 import Id1
from elabapi_python.models.id2 import Id2
from elabapi_python.models.id3 import Id3
from elabapi_python.models.id4 import Id4
from elabapi_python.models.id_certs_body import IdCertsBody
from elabapi_python.models.id_comments_body import IdCommentsBody
from elabapi_python.models.id_endpoints_body import IdEndpointsBody
from elabapi_python.models.id_experiments_categories_body import IdExperimentsCategoriesBody
from elabapi_python.models.id_experiments_status_body import IdExperimentsStatusBody
from elabapi_python.models.id_items_status_body import IdItemsStatusBody
from elabapi_python.models.id_resources_categories_body import IdResourcesCategoriesBody
from elabapi_python.models.id_steps_body import IdStepsBody
from elabapi_python.models.id_tags_body import IdTagsBody
from elabapi_python.models.id_tags_body1 import IdTagsBody1
from elabapi_python.models.id_teamgroups_body import IdTeamgroupsBody
from elabapi_python.models.id_uploads_body import IdUploadsBody
from elabapi_python.models.idp import Idp
from elabapi_python.models.idp_cert import IdpCert
from elabapi_python.models.idp_endpoint import IdpEndpoint
from elabapi_python.models.idp_source import IdpSource
from elabapi_python.models.idps_body import IdpsBody
from elabapi_python.models.idps_id_body import IdpsIdBody
from elabapi_python.models.idps_sources_body import IdpsSourcesBody
from elabapi_python.models.idps_sources_id_body import IdpsSourcesIdBody
from elabapi_python.models.import_body import ImportBody
from elabapi_python.models.inline_response200 import InlineResponse200
from elabapi_python.models.inline_response2001 import InlineResponse2001
from elabapi_python.models.inline_response20010 import InlineResponse20010
from elabapi_python.models.inline_response2001_experiments import InlineResponse2001Experiments
from elabapi_python.models.inline_response2001_items import InlineResponse2001Items
from elabapi_python.models.inline_response2001_users import InlineResponse2001Users
from elabapi_python.models.inline_response2002 import InlineResponse2002
from elabapi_python.models.inline_response2003 import InlineResponse2003
from elabapi_python.models.inline_response2004 import InlineResponse2004
from elabapi_python.models.inline_response2004_query_parameters import InlineResponse2004QueryParameters
from elabapi_python.models.inline_response2005 import InlineResponse2005
from elabapi_python.models.inline_response2006 import InlineResponse2006
from elabapi_python.models.inline_response2007 import InlineResponse2007
from elabapi_python.models.inline_response2008 import InlineResponse2008
from elabapi_python.models.inline_response2009 import InlineResponse2009
from elabapi_python.models.inline_response2009_embedded import InlineResponse2009Embedded
from elabapi_python.models.inline_response2009_embedded_entries import InlineResponse2009EmbeddedEntries
from elabapi_python.models.instance_body import InstanceBody
from elabapi_python.models.item import Item
from elabapi_python.models.item_editable import ItemEditable
from elabapi_python.models.items_links_subid_body import ItemsLinksSubidBody
from elabapi_python.models.items_types_id_body import ItemsTypesIdBody
from elabapi_python.models.link import Link
from elabapi_python.models.metadata import Metadata
from elabapi_python.models.metadata_elabftw import MetadataElabftw
from elabapi_python.models.metadata_elabftw_extra_fields_groups import MetadataElabftwExtraFieldsGroups
from elabapi_python.models.metadata_extra_fields import MetadataExtraFields
from elabapi_python.models.notification import Notification
from elabapi_python.models.notifications_subid_body import NotificationsSubidBody
from elabapi_python.models.patch_action import PatchAction
from elabapi_python.models.revision import Revision
from elabapi_python.models.revisions_subid_body import RevisionsSubidBody
from elabapi_python.models.statuslike import Statuslike
from elabapi_python.models.step import Step
from elabapi_python.models.steps_subid_body import StepsSubidBody
from elabapi_python.models.storage_unit import StorageUnit
from elabapi_python.models.storage_units_body import StorageUnitsBody
from elabapi_python.models.storage_units_id_body import StorageUnitsIdBody
from elabapi_python.models.tag import Tag
from elabapi_python.models.tags_subid_body import TagsSubidBody
from elabapi_python.models.tags_subid_body1 import TagsSubidBody1
from elabapi_python.models.team import Team
from elabapi_python.models.teamgroup import Teamgroup
from elabapi_python.models.teamgroup_users import TeamgroupUsers
from elabapi_python.models.teamgroups_subid_body import TeamgroupsSubidBody
from elabapi_python.models.teams_body import TeamsBody
from elabapi_python.models.teams_id_body import TeamsIdBody
from elabapi_python.models.todoitem import Todoitem
from elabapi_python.models.todolist_body import TodolistBody
from elabapi_python.models.todolist_id_body import TodolistIdBody
from elabapi_python.models.unfinished_step import UnfinishedStep
from elabapi_python.models.unfinished_steps import UnfinishedSteps
from elabapi_python.models.upload import Upload
from elabapi_python.models.uploads_subid_body import UploadsSubidBody
from elabapi_python.models.uploads_subid_body1 import UploadsSubidBody1
from elabapi_python.models.users import Users
from elabapi_python.models.users_body import UsersBody
from elabapi_python.models.users_full import UsersFull
from elabapi_python.models.users_full_teams import UsersFullTeams
from elabapi_python.models.users_id_body import UsersIdBody

Some files were not shown because too many files have changed in this diff Show More