chemunited-workflow 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. chemunited_workflow-0.0.1/LICENSE +21 -0
  2. chemunited_workflow-0.0.1/PKG-INFO +47 -0
  3. chemunited_workflow-0.0.1/README.md +388 -0
  4. chemunited_workflow-0.0.1/chemunited_workflow/__init__.py +46 -0
  5. chemunited_workflow-0.0.1/chemunited_workflow/api/__init__.py +41 -0
  6. chemunited_workflow-0.0.1/chemunited_workflow/api/dependencies.py +35 -0
  7. chemunited_workflow-0.0.1/chemunited_workflow/api/project_holder.py +80 -0
  8. chemunited_workflow-0.0.1/chemunited_workflow/api/routers/__init__.py +0 -0
  9. chemunited_workflow-0.0.1/chemunited_workflow/api/routers/components.py +30 -0
  10. chemunited_workflow-0.0.1/chemunited_workflow/api/routers/logs.py +60 -0
  11. chemunited_workflow-0.0.1/chemunited_workflow/api/routers/processes.py +53 -0
  12. chemunited_workflow-0.0.1/chemunited_workflow/api/routers/project.py +53 -0
  13. chemunited_workflow-0.0.1/chemunited_workflow/api/routers/runner.py +196 -0
  14. chemunited_workflow-0.0.1/chemunited_workflow/api/routers/snapshots.py +73 -0
  15. chemunited_workflow-0.0.1/chemunited_workflow/api/run_store.py +79 -0
  16. chemunited_workflow-0.0.1/chemunited_workflow/api/schemas.py +110 -0
  17. chemunited_workflow-0.0.1/chemunited_workflow/api/services/__init__.py +0 -0
  18. chemunited_workflow-0.0.1/chemunited_workflow/api/services/protocol.py +238 -0
  19. chemunited_workflow-0.0.1/chemunited_workflow/api/services/runner.py +152 -0
  20. chemunited_workflow-0.0.1/chemunited_workflow/cli.py +114 -0
  21. chemunited_workflow-0.0.1/chemunited_workflow/clients.py +348 -0
  22. chemunited_workflow-0.0.1/chemunited_workflow/compiler.py +135 -0
  23. chemunited_workflow-0.0.1/chemunited_workflow/durations.py +32 -0
  24. chemunited_workflow-0.0.1/chemunited_workflow/enums.py +28 -0
  25. chemunited_workflow-0.0.1/chemunited_workflow/exceptions.py +5 -0
  26. chemunited_workflow-0.0.1/chemunited_workflow/executor.py +548 -0
  27. chemunited_workflow-0.0.1/chemunited_workflow/mcp/__init__.py +32 -0
  28. chemunited_workflow-0.0.1/chemunited_workflow/mcp/tools.py +286 -0
  29. chemunited_workflow-0.0.1/chemunited_workflow/models.py +170 -0
  30. chemunited_workflow-0.0.1/chemunited_workflow/monitoring.py +5 -0
  31. chemunited_workflow-0.0.1/chemunited_workflow/platform.py +102 -0
  32. chemunited_workflow-0.0.1/chemunited_workflow/process.py +169 -0
  33. chemunited_workflow-0.0.1/chemunited_workflow/project_loader.py +78 -0
  34. chemunited_workflow-0.0.1/chemunited_workflow/quantity.py +146 -0
  35. chemunited_workflow-0.0.1/chemunited_workflow/terminal.py +76 -0
  36. chemunited_workflow-0.0.1/chemunited_workflow.egg-info/PKG-INFO +47 -0
  37. chemunited_workflow-0.0.1/chemunited_workflow.egg-info/SOURCES.txt +41 -0
  38. chemunited_workflow-0.0.1/chemunited_workflow.egg-info/dependency_links.txt +1 -0
  39. chemunited_workflow-0.0.1/chemunited_workflow.egg-info/entry_points.txt +2 -0
  40. chemunited_workflow-0.0.1/chemunited_workflow.egg-info/requires.txt +20 -0
  41. chemunited_workflow-0.0.1/chemunited_workflow.egg-info/top_level.txt +1 -0
  42. chemunited_workflow-0.0.1/pyproject.toml +46 -0
  43. chemunited_workflow-0.0.1/setup.cfg +4 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Automated Chemistry @ Max Plank Institute of Colloids and Interfaces
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: chemunited-workflow
3
+ Version: 0.0.1
4
+ Summary: Conditional NetworkX-based workflow execution with loopbacks and parallel branches.
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 Automated Chemistry @ Max Plank Institute of Colloids and Interfaces
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ Requires-Python: >=3.11
28
+ License-File: LICENSE
29
+ Requires-Dist: networkx>=3.2
30
+ Requires-Dist: pydantic>=2.0
31
+ Requires-Dist: loguru>=0.7
32
+ Requires-Dist: rich>=13.0
33
+ Requires-Dist: requests>=2.31
34
+ Requires-Dist: fastapi!=0.136.3,>=0.111
35
+ Requires-Dist: starlette>=1.0.1
36
+ Requires-Dist: mcp[cli]>=1.0
37
+ Requires-Dist: click>=8.1
38
+ Requires-Dist: pint>=0.23
39
+ Requires-Dist: pydantic-pint>=0.2
40
+ Provides-Extra: server
41
+ Requires-Dist: uvicorn>=0.29; extra == "server"
42
+ Provides-Extra: test
43
+ Requires-Dist: pytest>=8.0; extra == "test"
44
+ Requires-Dist: pytest-mock>=3.12; extra == "test"
45
+ Requires-Dist: responses>=0.25; extra == "test"
46
+ Requires-Dist: httpx>=0.27; extra == "test"
47
+ Dynamic: license-file
@@ -0,0 +1,388 @@
1
+ # chemunited-workflow
2
+
3
+ A NetworkX-based workflow execution engine for conditional automation of chemistry experiments. Designed for hardware-in-the-loop laboratory protocols where operations depend on device responses, physical measurements, and branching conditions.
4
+
5
+ ## Features
6
+
7
+ - **Conditional DAG execution** with loopbacks and parallel branches
8
+ - **Device-centric HTTP clients** for hardware control (pipettes, reactors, pumps, etc.)
9
+ - **Thread-safe parallel node execution** via `ThreadPoolExecutor`
10
+ - **Physical unit handling** (volumes, temperatures, concentrations) using Pint
11
+ - **Multiple deployment modes**: FastAPI REST API, MCP server, or direct Python execution
12
+ - **Protocol versioning** with snapshot persistence and schema validation
13
+
14
+ ## Requirements
15
+
16
+ - Python >= 3.11
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ git clone https://github.com/automatedchemistry/chemunited-workflow.git
22
+ cd chemunited-workflow
23
+
24
+ python -m venv .venv
25
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
26
+
27
+ # Core package
28
+ pip install -e .
29
+
30
+ # With API and MCP server support
31
+ pip install -e ".[server]"
32
+
33
+ # With test dependencies
34
+ pip install -e ".[test]"
35
+ ```
36
+
37
+ ## Quick Start
38
+
39
+ ### 1. Define a workflow
40
+
41
+ ```python
42
+ from chemunited_workflow import Process, Platform
43
+ import networkx as nx
44
+ from pydantic import BaseModel
45
+
46
+ class MyConfig(BaseModel):
47
+ volume_ul: float
48
+ temperature_c: float
49
+
50
+ class MyWorkflow(Process[MyConfig]):
51
+ def build_workflow(self) -> nx.DiGraph:
52
+ G = nx.DiGraph()
53
+ G.add_node("dispense", method="dispense_step")
54
+ G.add_node("heat", method="heat_step")
55
+ G.add_node("verify", method="verify_step")
56
+ G.add_edge("dispense", "heat", condition=True)
57
+ G.add_edge("heat", "verify", condition=True)
58
+ return G
59
+
60
+ def dispense_step(self, ctx):
61
+ client = self.platform["pump_01"]
62
+ r = client.post("dispense", json={"volume": self.config.volume_ul})
63
+ return r.status_code == 200
64
+
65
+ def heat_step(self, ctx):
66
+ client = self.platform["reactor_01"]
67
+ r = client.post("set_temp", json={"temp": self.config.temperature_c})
68
+ return r.status_code == 200
69
+
70
+ def verify_step(self, ctx):
71
+ client = self.platform["reactor_01"]
72
+ r = client.get("status")
73
+ return r.json()["ready"]
74
+ ```
75
+
76
+ ### 2. Configure device connectivity
77
+
78
+ Create `connectivity/associations.json`:
79
+
80
+ ```json
81
+ {
82
+ "server_url": "http://192.168.1.10",
83
+ "associations": [
84
+ { "component": "pump_01", "component_url": "devices/pump_01" },
85
+ { "component": "reactor_01", "component_url": "devices/reactor_01" }
86
+ ]
87
+ }
88
+ ```
89
+
90
+ ### 3. Run the workflow
91
+
92
+ ```python
93
+ from pathlib import Path
94
+
95
+ platform = Platform.from_project_dir(Path("my_project"))
96
+ config = MyConfig(volume_ul=500, temperature_c=60.0)
97
+ workflow = MyWorkflow(config, platform)
98
+ result = workflow.run_workflow(start_node="dispense")
99
+ print(result)
100
+ ```
101
+
102
+ ## Project Structure
103
+
104
+ A typical project using this library looks like:
105
+
106
+ ```
107
+ my_project/
108
+ ├── protocols/
109
+ │ ├── __init__.py # PROCESSES and CONFIGS dicts
110
+ │ ├── main_parameters.py # MainParameter Pydantic model
111
+ │ └── my_workflow.py # Process subclasses (also readable via read_process)
112
+ ├── connectivity/
113
+ │ └── associations.json # Device URL mapping
114
+ ├── protocols_hystoric/ # Versioned protocol snapshots
115
+ └── log/ # Execution logs
116
+ └── archive/ # Archived logs (populated by archive_log)
117
+ ```
118
+
119
+ `protocols/__init__.py` must export:
120
+
121
+ ```python
122
+ from .my_workflow import MyWorkflow, MyConfig
123
+
124
+ PROCESSES = {"my_workflow": MyWorkflow}
125
+ CONFIGS = {"my_workflow": MyConfig}
126
+ ```
127
+
128
+ ## Deployment Modes
129
+
130
+ ### FastAPI server
131
+
132
+ The FastAPI server is project-agnostic. You can start it without a project and load one at runtime, or pass a project directory as a shortcut to pre-load it at startup.
133
+
134
+ ```bash
135
+ # Start without a project — load one via PUT /project after startup
136
+ chemunited-workflow --fastapi --port 3116
137
+
138
+ # Start with a project pre-loaded (shortcut)
139
+ chemunited-workflow my_project --fastapi --port 3116
140
+
141
+ # Development with auto-reload
142
+ chemunited-workflow --fastapi --reload
143
+ ```
144
+
145
+ To load or switch projects at runtime:
146
+
147
+ ```bash
148
+ curl -X PUT http://127.0.0.1:3116/project/ \
149
+ -H "Content-Type: application/json" \
150
+ -d '{"project_dir": "/absolute/path/to/my_project"}'
151
+ ```
152
+
153
+ > **Windows paths:** Use forward slashes (`C:/Users/...`) or escaped backslashes (`C:\\Users\\...`) in JSON — bare backslashes are not valid JSON.
154
+
155
+ You can switch to a different project at any time, as long as no run is currently active.
156
+
157
+ ### MCP server
158
+
159
+ The MCP server is project-agnostic. It starts without a project and the LLM loads one at runtime by calling the `load_project` tool.
160
+
161
+ ```bash
162
+ # MCP server over stdio — expose workflows as tools to Claude or other agents
163
+ chemunited-workflow --mcp
164
+
165
+ # MCP server over streamable HTTP, exposed at http://127.0.0.1:3117/mcp
166
+ chemunited-workflow --mcp-http --port 3117
167
+ ```
168
+
169
+ ### MCP stdio
170
+
171
+ The MCP server runs over **stdio** by default. It does not expose an HTTP
172
+ address in this mode. Instead, the LLM client starts the server command and
173
+ communicates through the process stdin/stdout streams:
174
+
175
+ ```json
176
+ {
177
+ "mcpServers": {
178
+ "chemunited-workflow": {
179
+ "command": "chemunited-workflow",
180
+ "args": ["--mcp"]
181
+ }
182
+ }
183
+ }
184
+ ```
185
+
186
+ If you want to pin to a specific virtual environment, point `command` at that
187
+ environment's script:
188
+
189
+ ```json
190
+ {
191
+ "mcpServers": {
192
+ "chemunited-workflow": {
193
+ "command": "/absolute/path/to/.venv/bin/chemunited-workflow",
194
+ "args": ["--mcp"]
195
+ }
196
+ }
197
+ }
198
+ ```
199
+
200
+ On Windows, assuming this repository is checked out at `D:\Projects\chemunited-workflow`:
201
+
202
+ ```json
203
+ {
204
+ "mcpServers": {
205
+ "chemunited-workflow": {
206
+ "command": "D:\\Projects\\chemunited-workflow\\.venv\\Scripts\\chemunited-workflow.exe",
207
+ "args": ["--mcp"]
208
+ }
209
+ }
210
+ }
211
+ ```
212
+
213
+ ### MCP HTTP
214
+
215
+ Use streamable HTTP when your MCP client asks for a URL or when you want the
216
+ server to run independently of the LLM client process:
217
+
218
+ ```bash
219
+ chemunited-workflow --mcp-http --host 127.0.0.1 --port 3117
220
+ ```
221
+
222
+ The MCP HTTP address is:
223
+
224
+ ```text
225
+ http://127.0.0.1:3117/mcp
226
+ ```
227
+
228
+ On Windows:
229
+
230
+ ```bash
231
+ .venv\Scripts\chemunited-workflow.exe --mcp-http --port 3117
232
+ ```
233
+
234
+ Use `--mcp-path` to change the endpoint path:
235
+
236
+ ```bash
237
+ chemunited-workflow --mcp-http --port 3117 --mcp-path /chemunited-mcp
238
+ ```
239
+
240
+ Then the address becomes `http://127.0.0.1:3117/chemunited-mcp`.
241
+
242
+ MCP HTTP is separate from the FastAPI REST API. FastAPI uses
243
+ `http://127.0.0.1:3116/docs`; MCP HTTP uses the MCP endpoint path, such as
244
+ `http://127.0.0.1:3117/mcp`.
245
+
246
+ > **Note:** Once connected, ask the LLM to call `load_project` with the path to
247
+ > your project directory. All other tools return an error until a project is loaded.
248
+
249
+ ## API Overview
250
+
251
+ When running in `--fastapi` mode the following endpoints are available.
252
+
253
+ All endpoints except `GET /project/` return HTTP `503` if no project has been loaded yet.
254
+
255
+ ### Project management
256
+
257
+ | Method | Path | Description |
258
+ |--------|------|-------------|
259
+ | `GET` | `/project/` | Return the currently loaded project directory, or `null` if none is loaded. Always returns `200` — use this as a readiness probe. |
260
+ | `PUT` | `/project/` | Load or switch the active project. Body: `{"project_dir": "/path/to/project"}`. Returns `409` if a run is currently active. |
261
+
262
+ ### Processes
263
+
264
+ | Method | Path | Description |
265
+ |--------|------|-------------|
266
+ | `GET` | `/processes/` | List available workflow processes |
267
+ | `GET` | `/processes/{name}/schema` | JSON schema for a process config |
268
+ | `GET` | `/processes/{name}/source` | Full source code of a process file |
269
+
270
+ ### Snapshots
271
+
272
+ | Method | Path | Description |
273
+ |--------|------|-------------|
274
+ | `GET` | `/snapshots/` | List saved protocol snapshots |
275
+ | `GET` | `/snapshots/{filename}` | Read a snapshot by filename |
276
+ | `POST` | `/snapshots/` | Save a new versioned snapshot |
277
+ | `DELETE` | `/snapshots/{filename}` | Permanently delete a snapshot |
278
+
279
+ ### Run control
280
+
281
+ | Method | Path | Description |
282
+ |--------|------|-------------|
283
+ | `POST` | `/run/` | Start a workflow run from a snapshot. Body: `{"snapshot": "<snapshot filename>", "dry_run": false}`. `snapshot` is required; `dry_run` defaults to `false`. Returns HTTP `202` with a `run_id`. |
284
+ | `GET` | `/run/active` | Return the active run id as `{"run_id": "<id>"}` or `{"run_id": null}` |
285
+ | `GET` | `/run/{run_id}/status` | Poll run state and events. Events are cleared after each read; terminal states are `finished`, `failed`, and `cancelled`. |
286
+ | `GET` | `/run/{run_id}/report` | Full execution report for a finished run |
287
+ | `DELETE` | `/run/{run_id}` | Cancel an active run |
288
+ | `GET` | `/run/{run_id}/stream` | Stream workflow events for a run |
289
+ | `GET` | `/run/pool` | Drain pending device commands and delete their pool files; returns an empty list when no commands are pending |
290
+
291
+ Example `POST /run/` request:
292
+
293
+ ```json
294
+ {
295
+ "snapshot": "snapshot_20250101T120000.json",
296
+ "dry_run": false
297
+ }
298
+ ```
299
+
300
+ Use `dry_run: true` to simulate device calls. The workflow graph and node logic still run, but physical HTTP calls are suppressed.
301
+
302
+ `POST /run/` accepts `timeout_commands` in the JSON body. Use values such as
303
+ `"5 s"` or `"2 min"` to control feedback polling timeout; omit it for the
304
+ default `"10 s"`, or pass `""` to poll without a timeout.
305
+
306
+ ### Logs
307
+
308
+ | Method | Path | Description |
309
+ |--------|------|-------------|
310
+ | `GET` | `/logs/` | List log file metadata, sorted most recent first |
311
+ | `GET` | `/logs/search?query=...&max_results=50` | Search all active log files for matching lines, case-insensitive |
312
+ | `GET` | `/logs/{filename}?tail=N` | Read a log file. `tail` is optional and returns only the last `N` lines. |
313
+ | `POST` | `/logs/{filename}/archive` | Move a log to `log/archive/` |
314
+
315
+ ### Components
316
+
317
+ | Method | Path | Description |
318
+ |--------|------|-------------|
319
+ | `GET` | `/components/` | Return the full `associations.json` map |
320
+ | `GET` | `/components/ping?timeout=2.0` | Check reachability of every device URL |
321
+
322
+ Visit `/docs` for the interactive Swagger UI.
323
+
324
+ ## MCP Tools
325
+
326
+ When running in `--mcp` or `--mcp-http` mode, the following tools are exposed to the connected LLM agent:
327
+
328
+ | Tool | Description |
329
+ |------|-------------|
330
+ | `load_project` | Load or switch the active project by directory path. Rejected if a run is active. |
331
+ | `get_project` | Return the currently loaded project path, or null if none is loaded. |
332
+ | `list_processes` | Discover available process names and schemas |
333
+ | `get_process_schema` | Full parameter schema for a named process |
334
+ | `read_process` | Source code of a process definition file |
335
+ | `list_snapshots` | List snapshots in `protocols_hystoric/` |
336
+ | `get_snapshot` | Read a snapshot's full JSON content |
337
+ | `create_snapshot` | Validate and save a new versioned snapshot |
338
+ | `delete_snapshot` | Permanently delete a snapshot |
339
+ | `start_run` | Execute a snapshot; returns a `run_id` |
340
+ | `get_run_status` | Poll run state and events |
341
+ | `get_run_report` | Full per-step execution report |
342
+ | `cancel_run` | Cancel an active run |
343
+ | `get_components` | Return the device connectivity map |
344
+ | `ping_components` | Check reachability of all device URLs |
345
+ | `list_logs` | List log files |
346
+ | `read_log` | Read a log file's text content |
347
+ | `search_logs` | Search log files for a query string |
348
+ | `archive_log` | Move a log file to `log/archive/` |
349
+
350
+ ## How Execution Works
351
+
352
+ 1. **Graph definition** — `build_workflow()` returns a `networkx.DiGraph` where each node has a `method` attribute pointing to a method on the `Process` class, and edges carry boolean `condition` values.
353
+ 2. **Compilation** — `compile_workflow()` validates the graph, extracts loopback edges (cycles), and ensures the remaining graph is a DAG.
354
+ 3. **Execution** — `WorkflowExecutor` traverses the compiled graph using a `ThreadPoolExecutor`: nodes whose predecessors have all completed are scheduled concurrently; edge conditions are evaluated to route execution; loopbacks are triggered when a node returns `True` to repeat a section.
355
+ 4. **Events** — The executor emits `WorkflowExecutionEvent` objects for each state transition, consumed by the API to provide real-time status and log streaming.
356
+ 5. **Result** — A `WorkflowResult` is returned with the final state, per-node results, runtime, and any errors.
357
+
358
+ ## Physical Units
359
+
360
+ Use `ChemUnitQuantity` for values that carry SI units:
361
+
362
+ ```python
363
+ from chemunited_workflow import ChemUnitQuantity
364
+
365
+ volume = ChemUnitQuantity.parse("500 ul")
366
+ double = volume * 2 # 1000 ul
367
+ in_ml = volume.to("ml") # 0.5 ml
368
+ ```
369
+
370
+ Units are validated and propagated through arithmetic. `ChemQuantityValidator` integrates with Pydantic models.
371
+
372
+ ## Dry-Run Mode
373
+
374
+ Pass `dry_run=True` to `Platform` to suppress all HTTP calls — useful for testing graph logic without hardware:
375
+
376
+ ```python
377
+ platform = Platform.from_connectivity(path, dry_run=True)
378
+ ```
379
+
380
+ ## Running Tests
381
+
382
+ ```bash
383
+ pytest tests/
384
+ ```
385
+
386
+ ## License
387
+
388
+ MIT — Automated Chemistry, Max Planck Institute for Colloids and Interfaces.
@@ -0,0 +1,46 @@
1
+ """Public API for the workflow package."""
2
+
3
+ from .clients import BaseClient, ComponentClient
4
+ from .compiler import compile_workflow
5
+ from .enums import NodeState, WorkflowEventType
6
+ from .exceptions import ConcurrentClientAccessError
7
+ from .executor import WorkflowExecutor
8
+ from .models import (
9
+ LoopBackSpec,
10
+ NodeConfig,
11
+ NodeExecutionContext,
12
+ NodeRuntime,
13
+ WorkflowEdgeSpec,
14
+ WorkflowExecutionEvent,
15
+ WorkflowNodeSpec,
16
+ WorkflowResult,
17
+ )
18
+ from .platform import Platform
19
+ from .process import Process
20
+ from .quantity import ChemUnitQuantity, ChemQuantityValidator
21
+ from .terminal import WorkflowLogger, configure_terminal_logging, create_run_log_path
22
+
23
+ __all__ = [
24
+ "Process",
25
+ "Platform",
26
+ "BaseClient",
27
+ "ComponentClient",
28
+ "ConcurrentClientAccessError",
29
+ "WorkflowExecutor",
30
+ "compile_workflow",
31
+ "NodeConfig",
32
+ "WorkflowNodeSpec",
33
+ "WorkflowEdgeSpec",
34
+ "LoopBackSpec",
35
+ "NodeRuntime",
36
+ "NodeExecutionContext",
37
+ "WorkflowResult",
38
+ "NodeState",
39
+ "WorkflowEventType",
40
+ "WorkflowExecutionEvent",
41
+ "WorkflowLogger",
42
+ "configure_terminal_logging",
43
+ "create_run_log_path",
44
+ "ChemUnitQuantity",
45
+ "ChemQuantityValidator",
46
+ ]
@@ -0,0 +1,41 @@
1
+ """chemunited_workflow.api — FastAPI application factory."""
2
+
3
+ from fastapi import FastAPI
4
+ from fastapi.responses import RedirectResponse
5
+
6
+ from .dependencies import get_project_holder
7
+ from .project_holder import ProjectHolder
8
+ from .routers.components import router as components_router
9
+ from .routers.logs import router as logs_router
10
+ from .routers.processes import router as processes_router
11
+ from .routers.project import router as project_router
12
+ from .routers.runner import router as runner_router
13
+ from .routers.snapshots import read_router as snapshots_read_router
14
+ from .routers.snapshots import write_router as snapshots_write_router
15
+
16
+
17
+ def create_api() -> FastAPI:
18
+ """Create and return a configured FastAPI application.
19
+
20
+ The server starts with no project loaded. Use ``PUT /project`` to load a
21
+ project directory at runtime.
22
+ """
23
+ holder = ProjectHolder()
24
+
25
+ app = FastAPI(title="chemunited API")
26
+
27
+ @app.get("/", include_in_schema=False)
28
+ async def root():
29
+ return RedirectResponse(url="/docs")
30
+
31
+ app.dependency_overrides[get_project_holder] = lambda: holder
32
+
33
+ app.include_router(project_router)
34
+ app.include_router(processes_router)
35
+ app.include_router(snapshots_read_router)
36
+ app.include_router(snapshots_write_router)
37
+ app.include_router(runner_router)
38
+ app.include_router(components_router)
39
+ app.include_router(logs_router)
40
+
41
+ return app
@@ -0,0 +1,35 @@
1
+ """FastAPI dependency functions for the chemunited API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import Depends, HTTPException
6
+
7
+ from .project_holder import ProjectHolder
8
+ from .services.protocol import ProtocolService
9
+ from .services.runner import RunnerService
10
+
11
+ _NO_PROJECT_MSG = (
12
+ "No project loaded. Use PUT /project to load a project directory first."
13
+ )
14
+
15
+
16
+ def get_project_holder() -> ProjectHolder:
17
+ raise NotImplementedError("Dependency not wired — was create_api() called?")
18
+
19
+
20
+ def get_protocol_service(
21
+ holder: ProjectHolder = Depends(get_project_holder),
22
+ ) -> ProtocolService:
23
+ svc = holder.protocol_service
24
+ if svc is None:
25
+ raise HTTPException(status_code=503, detail=_NO_PROJECT_MSG)
26
+ return svc
27
+
28
+
29
+ def get_runner_service(
30
+ holder: ProjectHolder = Depends(get_project_holder),
31
+ ) -> RunnerService:
32
+ svc = holder.runner_service
33
+ if svc is None:
34
+ raise HTTPException(status_code=503, detail=_NO_PROJECT_MSG)
35
+ return svc
@@ -0,0 +1,80 @@
1
+ """ProjectHolder — manages the optional active project and its services."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from pathlib import Path
7
+
8
+ from chemunited_workflow.project_loader import ProjectModules
9
+
10
+ from .run_store import RunStore
11
+ from .services.protocol import ProtocolService
12
+ from .services.runner import RunnerService
13
+
14
+
15
+ class ProjectHolder:
16
+ """Thread-safe holder for the currently active project's service instances.
17
+
18
+ The ``RunStore`` is created once at construction and persists across project
19
+ switches so that in-flight run history is not lost.
20
+ """
21
+
22
+ def __init__(self) -> None:
23
+ self._lock = threading.Lock()
24
+ self._run_store = RunStore()
25
+ self._project_dir: Path | None = None
26
+ self._protocol_service: ProtocolService | None = None
27
+ self._runner_service: RunnerService | None = None
28
+
29
+ # ── Read accessors ────────────────────────────────────────────────────────
30
+
31
+ @property
32
+ def project_dir(self) -> Path | None:
33
+ with self._lock:
34
+ return self._project_dir
35
+
36
+ @property
37
+ def protocol_service(self) -> ProtocolService | None:
38
+ with self._lock:
39
+ return self._protocol_service
40
+
41
+ @property
42
+ def runner_service(self) -> RunnerService | None:
43
+ with self._lock:
44
+ return self._runner_service
45
+
46
+ @property
47
+ def run_store(self) -> RunStore:
48
+ return self._run_store
49
+
50
+ def is_loaded(self) -> bool:
51
+ with self._lock:
52
+ return self._project_dir is not None
53
+
54
+ def active_run_id(self) -> str | None:
55
+ return self._run_store.active_run_id
56
+
57
+ # ── Mutation ──────────────────────────────────────────────────────────────
58
+
59
+ def load(self, modules: ProjectModules) -> None:
60
+ """Replace the active project.
61
+
62
+ Builds fresh ``ProtocolService`` and ``RunnerService`` instances (reusing
63
+ the same ``RunStore``), then swaps them under the lock.
64
+ """
65
+ new_protocol = ProtocolService(
66
+ project_dir=modules.project_dir,
67
+ processes=modules.processes,
68
+ configs=modules.configs,
69
+ main_parameter_class=modules.main_parameter_class,
70
+ )
71
+ new_runner = RunnerService(
72
+ project_dir=modules.project_dir,
73
+ processes=modules.processes,
74
+ configs=modules.configs,
75
+ run_store=self._run_store,
76
+ )
77
+ with self._lock:
78
+ self._project_dir = modules.project_dir
79
+ self._protocol_service = new_protocol
80
+ self._runner_service = new_runner
@@ -0,0 +1,30 @@
1
+ """Routes: GET /components."""
2
+
3
+ from fastapi import APIRouter, Depends
4
+
5
+ from ..dependencies import get_protocol_service
6
+ from ..schemas import ComponentStatus
7
+ from ..services.protocol import ProtocolService
8
+
9
+ router = APIRouter(prefix="/components", tags=["components"])
10
+
11
+
12
+ @router.get("/ping", response_model=list[ComponentStatus])
13
+ async def ping_components(
14
+ timeout: float = 2.0,
15
+ svc: ProtocolService = Depends(get_protocol_service),
16
+ ):
17
+ """Verify that all device URLs in ``associations.json`` are reachable."""
18
+ return svc.ping_components(timeout=timeout)
19
+
20
+
21
+ @router.get("/")
22
+ async def get_components(svc: ProtocolService = Depends(get_protocol_service)):
23
+ """Return the device connectivity map.
24
+
25
+ Returns the full contents of `connectivity/associations.json` — the
26
+ mapping of component names to their device-server URLs. Entries with an
27
+ empty `component_url` are included as-is; they represent devices that are
28
+ physically present but not yet wired to a server endpoint.
29
+ """
30
+ return svc.read_components()