Initial version
This commit is contained in:
@@ -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?
|
||||
@@ -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"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""LabVoice MCP server: aggregate eLabFTW tools for voice-driven wetlab use."""
|
||||
|
||||
__version__ = "0.0.1"
|
||||
@@ -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.
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Aggregate workflow services (the point of the server)."""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
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.
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.
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Vendored
+19
@@ -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's API version 2. # noqa: E501
|
||||
|
||||
Vendored
+417
@@ -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 `?hierarchy=true`.
|
||||
*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 \"real_name\", \"comment\" or \"state\" 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
|
||||
|
||||
|
||||
@@ -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'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
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user