benchmesh 0.1.0__py3-none-any.whl
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.
- benchmesh/__init__.py +53 -0
- benchmesh/_validate.py +42 -0
- benchmesh/_version.py +1 -0
- benchmesh/bench.py +203 -0
- benchmesh/errors.py +75 -0
- benchmesh/flows.py +173 -0
- benchmesh/instrument.py +225 -0
- benchmesh/py.typed +0 -0
- benchmesh/recordings.py +270 -0
- benchmesh/signals.py +48 -0
- benchmesh/transport.py +341 -0
- benchmesh/values.py +103 -0
- benchmesh/watch.py +75 -0
- benchmesh-0.1.0.dist-info/METADATA +284 -0
- benchmesh-0.1.0.dist-info/RECORD +17 -0
- benchmesh-0.1.0.dist-info/WHEEL +4 -0
- benchmesh-0.1.0.dist-info/licenses/LICENSE +21 -0
benchmesh/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Python client for BenchMesh, one cockpit for lab instruments.
|
|
2
|
+
|
|
3
|
+
>>> import benchmesh
|
|
4
|
+
>>> bench = benchmesh.connect() # the BenchMesh app on this computer
|
|
5
|
+
>>> psu = bench["tenma-72-2540"]
|
|
6
|
+
>>> psu.set("voltage", 5.0)
|
|
7
|
+
>>> psu.read("VOUT")
|
|
8
|
+
4.94
|
|
9
|
+
|
|
10
|
+
See https://benchmesh.online
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from ._version import __version__
|
|
14
|
+
from .bench import Bench, connect
|
|
15
|
+
from .errors import (
|
|
16
|
+
AuthenticationError,
|
|
17
|
+
BadRequest,
|
|
18
|
+
BenchMeshError,
|
|
19
|
+
FingerprintMismatch,
|
|
20
|
+
InstrumentError,
|
|
21
|
+
InstrumentUnavailable,
|
|
22
|
+
NotFound,
|
|
23
|
+
Refused,
|
|
24
|
+
ServerNotRunning,
|
|
25
|
+
TLSError,
|
|
26
|
+
)
|
|
27
|
+
from .flows import Flow, RunStatus
|
|
28
|
+
from .instrument import Instrument, Operation
|
|
29
|
+
from .recordings import Recording, Series
|
|
30
|
+
from .signals import Signal
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"AuthenticationError",
|
|
34
|
+
"BadRequest",
|
|
35
|
+
"Bench",
|
|
36
|
+
"BenchMeshError",
|
|
37
|
+
"FingerprintMismatch",
|
|
38
|
+
"Flow",
|
|
39
|
+
"Instrument",
|
|
40
|
+
"InstrumentError",
|
|
41
|
+
"InstrumentUnavailable",
|
|
42
|
+
"NotFound",
|
|
43
|
+
"Operation",
|
|
44
|
+
"Recording",
|
|
45
|
+
"Refused",
|
|
46
|
+
"RunStatus",
|
|
47
|
+
"Series",
|
|
48
|
+
"ServerNotRunning",
|
|
49
|
+
"Signal",
|
|
50
|
+
"TLSError",
|
|
51
|
+
"__version__",
|
|
52
|
+
"connect",
|
|
53
|
+
]
|
benchmesh/_validate.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Argument checks done before anything is sent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
#: The instrument classes the BenchMesh bench knows (other three-letter classes are allowed).
|
|
9
|
+
KNOWN_CLASSES = ("PSU", "DMM", "ELL", "AWG", "OSC", "LCR", "SAL")
|
|
10
|
+
|
|
11
|
+
_CLASS = re.compile(r"^[A-Z]{3}$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def check_class(cls: Any) -> str:
|
|
15
|
+
"""``"psu"`` -> ``"PSU"``; anything but three letters raises ``ValueError``."""
|
|
16
|
+
if not isinstance(cls, str):
|
|
17
|
+
raise TypeError(f"class must be a str such as 'PSU', not {type(cls).__name__}")
|
|
18
|
+
c = cls.strip().upper()
|
|
19
|
+
if not _CLASS.match(c):
|
|
20
|
+
raise ValueError(
|
|
21
|
+
f"invalid instrument class {cls!r}: three letters such as {', '.join(KNOWN_CLASSES)}"
|
|
22
|
+
)
|
|
23
|
+
return c
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check_channel(ch: Any) -> int:
|
|
27
|
+
"""Channel number 1-9."""
|
|
28
|
+
if isinstance(ch, bool) or not isinstance(ch, int):
|
|
29
|
+
raise TypeError(f"channel must be an int 1-9, not {type(ch).__name__}")
|
|
30
|
+
if not 1 <= ch <= 9:
|
|
31
|
+
raise ValueError(f"channel must be 1-9, not {ch}")
|
|
32
|
+
return ch
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def check_name(kind: str, name: Any) -> str:
|
|
36
|
+
"""A non-empty identifier (device id, operation, status field)."""
|
|
37
|
+
if not isinstance(name, str):
|
|
38
|
+
raise TypeError(f"{kind} must be a str, not {type(name).__name__}")
|
|
39
|
+
n = name.strip()
|
|
40
|
+
if not n or "/" in n:
|
|
41
|
+
raise ValueError(f"invalid {kind} {name!r}")
|
|
42
|
+
return n
|
benchmesh/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
benchmesh/bench.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""The bench: entry point to everything the server offers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Tuple, Union
|
|
7
|
+
|
|
8
|
+
from . import flows as _flows
|
|
9
|
+
from ._validate import check_class, check_name
|
|
10
|
+
from .errors import BenchMeshError, NotFound
|
|
11
|
+
from .flows import Flow, RunStatus
|
|
12
|
+
from .instrument import Instrument
|
|
13
|
+
from .recordings import Recording
|
|
14
|
+
from .signals import SignalSpec, to_signals
|
|
15
|
+
from .transport import DEFAULT_URL, Transport
|
|
16
|
+
from .watch import watch as _watch
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Bench:
|
|
20
|
+
"""A connection to one BenchMesh server. Create it with :func:`benchmesh.connect`."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, transport: Transport) -> None:
|
|
23
|
+
self._t = transport
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def url(self) -> str:
|
|
27
|
+
return self._t.url
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def transport(self) -> Transport:
|
|
31
|
+
return self._t
|
|
32
|
+
|
|
33
|
+
def __repr__(self) -> str:
|
|
34
|
+
return f"<Bench {self.url}>"
|
|
35
|
+
|
|
36
|
+
def __enter__(self) -> Bench:
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
def __exit__(self, *exc: Any) -> None:
|
|
40
|
+
self.close()
|
|
41
|
+
|
|
42
|
+
def close(self) -> None:
|
|
43
|
+
"""Sign out of remote access (nothing to do on this computer)."""
|
|
44
|
+
self._t.logout()
|
|
45
|
+
|
|
46
|
+
def version(self) -> Dict[str, Any]:
|
|
47
|
+
"""The server's version, e.g. ``{"version": "0.1.11", ...}``."""
|
|
48
|
+
return self._t.get("/version")
|
|
49
|
+
|
|
50
|
+
# ---- instruments ---------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def instruments(self, cls: Optional[str] = None) -> List[Instrument]:
|
|
53
|
+
"""Configured instruments, optionally only those of one class (``"PSU"``, ``"DMM"`` ...)."""
|
|
54
|
+
if cls is None:
|
|
55
|
+
data = self._t.get("/instruments")
|
|
56
|
+
else:
|
|
57
|
+
try:
|
|
58
|
+
data = self._t.get(f"/instruments/{check_class(cls)}")
|
|
59
|
+
except NotFound:
|
|
60
|
+
return [] # no instrument of that class
|
|
61
|
+
return [Instrument.from_json(d, self._t) for d in data or [] if isinstance(d, dict)]
|
|
62
|
+
|
|
63
|
+
def instrument(self, id: str) -> Instrument:
|
|
64
|
+
"""The instrument with this config id, e.g. ``"tenma-72-2540"``."""
|
|
65
|
+
id = check_name("instrument id", id)
|
|
66
|
+
found = self.instruments()
|
|
67
|
+
for inst in found:
|
|
68
|
+
if inst.id == id:
|
|
69
|
+
return inst
|
|
70
|
+
raise NotFound(f"no instrument {id!r}; configured: {[i.id for i in found]}")
|
|
71
|
+
|
|
72
|
+
def __getitem__(self, id: str) -> Instrument:
|
|
73
|
+
return self.instrument(id)
|
|
74
|
+
|
|
75
|
+
def watch(
|
|
76
|
+
self,
|
|
77
|
+
signals: Mapping[str, SignalSpec],
|
|
78
|
+
interval: float = 0.5,
|
|
79
|
+
duration: Optional[float] = None,
|
|
80
|
+
) -> Iterator[Tuple[float, Dict[str, Any]]]:
|
|
81
|
+
"""Yield ``(time_s, {label: value})`` whenever a watched reading is updated.
|
|
82
|
+
|
|
83
|
+
``signals``: ``{"v": ("owon-xdm1241", "DMM", "MEAS"), "i": ("owon-oel1515", "ELL", "IIN")}``
|
|
84
|
+
(a fourth tuple item is the channel).
|
|
85
|
+
"""
|
|
86
|
+
return _watch(self._t, signals, interval, duration)
|
|
87
|
+
|
|
88
|
+
# ---- recordings ------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def recordings(self) -> List[Recording]:
|
|
91
|
+
"""All recordings, newest first."""
|
|
92
|
+
return [
|
|
93
|
+
Recording.from_json(d, self._t) for d in self._t.get("/recordings") or [] if isinstance(d, dict)
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
def recording(self, id: int) -> Recording:
|
|
97
|
+
if isinstance(id, bool) or not isinstance(id, int):
|
|
98
|
+
raise TypeError("recording id must be an int")
|
|
99
|
+
return Recording.from_json(self._t.get(f"/recordings/{id}"), self._t)
|
|
100
|
+
|
|
101
|
+
def record(
|
|
102
|
+
self,
|
|
103
|
+
name: str,
|
|
104
|
+
signals: Mapping[str, SignalSpec],
|
|
105
|
+
interval_s: int = 1,
|
|
106
|
+
description: Optional[str] = None,
|
|
107
|
+
) -> Recording:
|
|
108
|
+
"""Start a recording of ``signals`` (``{label: (device_id, class, field[, channel])}``).
|
|
109
|
+
|
|
110
|
+
The server samples every ``interval_s`` whole seconds (1-3600). Use the result as a
|
|
111
|
+
context manager to stop it at the end of the block.
|
|
112
|
+
"""
|
|
113
|
+
if not isinstance(name, str) or not name.strip() or len(name.strip()) > 100:
|
|
114
|
+
raise ValueError("name must be 1-100 characters")
|
|
115
|
+
if isinstance(interval_s, bool) or not isinstance(interval_s, int) or not 1 <= interval_s <= 3600:
|
|
116
|
+
raise ValueError("interval_s must be an int 1-3600")
|
|
117
|
+
sig = to_signals(signals)
|
|
118
|
+
body: Dict[str, Any] = {"name": name.strip(), "interval_s": interval_s}
|
|
119
|
+
if description is not None:
|
|
120
|
+
body["description"] = str(description)
|
|
121
|
+
rec = Recording.from_json(self._t.post("/recordings", body), self._t)
|
|
122
|
+
try:
|
|
123
|
+
for label, s in sig.items():
|
|
124
|
+
rec.add(label, s)
|
|
125
|
+
except BaseException:
|
|
126
|
+
# do not leave a half-made recording behind
|
|
127
|
+
try:
|
|
128
|
+
rec.stop()
|
|
129
|
+
rec.delete()
|
|
130
|
+
except BenchMeshError:
|
|
131
|
+
pass
|
|
132
|
+
raise
|
|
133
|
+
return rec
|
|
134
|
+
|
|
135
|
+
# ---- automation --------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def flows(self) -> List[Flow]:
|
|
138
|
+
"""Saved flows."""
|
|
139
|
+
return _flows.list_flows(self._t)
|
|
140
|
+
|
|
141
|
+
def flow(self, id: str) -> Dict[str, Any]:
|
|
142
|
+
"""A saved flow document (``benchmesh.flow/v1``)."""
|
|
143
|
+
return _flows.get_flow(self._t, check_name("flow id", id))
|
|
144
|
+
|
|
145
|
+
def run_flow(
|
|
146
|
+
self,
|
|
147
|
+
flow: Union[str, Mapping[str, Any]],
|
|
148
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
149
|
+
*,
|
|
150
|
+
arm: bool = False,
|
|
151
|
+
wait: bool = True,
|
|
152
|
+
timeout: Optional[float] = None,
|
|
153
|
+
on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
154
|
+
) -> RunStatus:
|
|
155
|
+
"""Run a saved flow (by id) or a flow document.
|
|
156
|
+
|
|
157
|
+
``arm=True`` is required for flows that can switch outputs on. With ``wait`` it
|
|
158
|
+
returns when the run has ended (check ``.ok``); ``timeout`` aborts it; Ctrl-C aborts
|
|
159
|
+
it too. ``on_event`` gets every log event as it arrives.
|
|
160
|
+
"""
|
|
161
|
+
return _flows.run_flow(
|
|
162
|
+
self._t, flow, params=params, arm=arm, wait=wait, timeout=timeout, on_event=on_event
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def run_status(self) -> RunStatus:
|
|
166
|
+
"""The current or last run."""
|
|
167
|
+
return _flows.run_status(self._t)
|
|
168
|
+
|
|
169
|
+
def abort_run(self) -> None:
|
|
170
|
+
"""Abort the running flow (its cleanup still runs)."""
|
|
171
|
+
_flows.abort_run(self._t)
|
|
172
|
+
|
|
173
|
+
def continue_run(self) -> None:
|
|
174
|
+
"""Continue a run waiting at a ``prompt`` step."""
|
|
175
|
+
_flows.continue_run(self._t)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def connect(
|
|
179
|
+
url: Optional[str] = None,
|
|
180
|
+
*,
|
|
181
|
+
password: Optional[str] = None,
|
|
182
|
+
fingerprint: Optional[str] = None,
|
|
183
|
+
timeout: float = 30.0,
|
|
184
|
+
) -> Bench:
|
|
185
|
+
"""Connect to a BenchMesh server and check that it answers.
|
|
186
|
+
|
|
187
|
+
``url``: default ``$BENCHMESH_URL`` or ``http://127.0.0.1:57666`` (the app on this
|
|
188
|
+
computer). For remote access use ``https://<address>:57667`` with the ``password`` and
|
|
189
|
+
the certificate ``fingerprint`` shown in the app (or ``$BENCHMESH_PASSWORD`` /
|
|
190
|
+
``$BENCHMESH_FINGERPRINT``).
|
|
191
|
+
"""
|
|
192
|
+
url = url or os.environ.get("BENCHMESH_URL") or DEFAULT_URL
|
|
193
|
+
if url.strip().lower().startswith("https://"):
|
|
194
|
+
password = password if password is not None else os.environ.get("BENCHMESH_PASSWORD") or None
|
|
195
|
+
fingerprint = (
|
|
196
|
+
fingerprint if fingerprint is not None else os.environ.get("BENCHMESH_FINGERPRINT") or None
|
|
197
|
+
)
|
|
198
|
+
t = Transport(url, password=password, fingerprint=fingerprint, timeout=timeout)
|
|
199
|
+
if password is not None:
|
|
200
|
+
t.login()
|
|
201
|
+
bench = Bench(t)
|
|
202
|
+
bench.version()
|
|
203
|
+
return bench
|
benchmesh/errors.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Exceptions raised by the client.
|
|
2
|
+
|
|
3
|
+
Every error from the server is a :class:`BenchMeshError` with the HTTP ``status`` and the
|
|
4
|
+
server's ``detail`` message. Wrong arguments found before anything is sent raise the standard
|
|
5
|
+
``ValueError`` / ``TypeError``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BenchMeshError(Exception):
|
|
14
|
+
"""An error reported by (or while talking to) the BenchMesh server."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, detail: str, status: Optional[int] = None, body: Any = None) -> None:
|
|
17
|
+
super().__init__(detail if status is None else f"{status}: {detail}")
|
|
18
|
+
self.detail = detail
|
|
19
|
+
self.status = status
|
|
20
|
+
#: The decoded JSON error body (may carry more keys, e.g. ``energizes`` or ``issues``).
|
|
21
|
+
self.body = body
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ServerNotRunning(BenchMeshError):
|
|
25
|
+
"""No BenchMesh server answers at the address."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TLSError(BenchMeshError):
|
|
29
|
+
"""The HTTPS connection to remote access could not be trusted."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FingerprintMismatch(TLSError):
|
|
33
|
+
"""The server's certificate does not match the pinned fingerprint."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AuthenticationError(BenchMeshError):
|
|
37
|
+
"""Sign-in failed or the session is not allowed to do this (401 / 403)."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BadRequest(BenchMeshError):
|
|
41
|
+
"""The server refused the arguments (400 / 422), e.g. a value outside the setter's range."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class NotFound(BenchMeshError):
|
|
45
|
+
"""Unknown instrument, class, channel, operation, field, recording or flow (404)."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Refused(BenchMeshError):
|
|
49
|
+
"""A precondition is not met (409): parked instrument, busy run, flow needs ``arm``, ..."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class InstrumentError(BenchMeshError):
|
|
53
|
+
"""The instrument answered something the definition could not parse (502)."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class InstrumentUnavailable(BenchMeshError):
|
|
57
|
+
"""The instrument is not connected or did not answer in time (503 / 504)."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_BY_STATUS = {
|
|
61
|
+
400: BadRequest,
|
|
62
|
+
401: AuthenticationError,
|
|
63
|
+
403: AuthenticationError,
|
|
64
|
+
404: NotFound,
|
|
65
|
+
409: Refused,
|
|
66
|
+
422: BadRequest,
|
|
67
|
+
502: InstrumentError,
|
|
68
|
+
503: InstrumentUnavailable,
|
|
69
|
+
504: InstrumentUnavailable,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def error_for(status: int, detail: str, body: Any = None) -> BenchMeshError:
|
|
74
|
+
"""The exception for an HTTP error status."""
|
|
75
|
+
return _BY_STATUS.get(status, BenchMeshError)(detail, status, body)
|
benchmesh/flows.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Automation flows: list saved flows, run one, follow the run until it ends.
|
|
2
|
+
|
|
3
|
+
The server runs one flow at a time. A flow that can switch outputs on needs ``arm=True``;
|
|
4
|
+
without it the server refuses the run (:class:`~benchmesh.errors.Refused`, the operations
|
|
5
|
+
are in ``error.body["energizes"]``). A failed or aborted run switches off what it switched on.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Callable, Dict, List, Mapping, Optional, Union
|
|
14
|
+
|
|
15
|
+
from .errors import Refused
|
|
16
|
+
from .transport import Transport, segment
|
|
17
|
+
|
|
18
|
+
ACTIVE_STATES = ("running", "waiting", "cleanup")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Flow:
|
|
23
|
+
"""A saved flow (``GET /automation/flows``)."""
|
|
24
|
+
|
|
25
|
+
id: str
|
|
26
|
+
name: str
|
|
27
|
+
description: str = ""
|
|
28
|
+
updated_at: Optional[int] = None
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_json(cls, d: Mapping[str, Any]) -> Flow:
|
|
32
|
+
return cls(
|
|
33
|
+
id=str(d.get("id", "")),
|
|
34
|
+
name=str(d.get("name") or d.get("id") or ""),
|
|
35
|
+
description=str(d.get("description") or ""),
|
|
36
|
+
updated_at=d.get("updated_at"),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class RunStatus:
|
|
42
|
+
"""State of the current (or last) run."""
|
|
43
|
+
|
|
44
|
+
id: Optional[int]
|
|
45
|
+
#: ``idle``, ``running``, ``waiting`` (at a prompt), ``cleanup``, ``finished``, ``failed``, ``aborted``.
|
|
46
|
+
state: str
|
|
47
|
+
flow_name: str = ""
|
|
48
|
+
error: Optional[str] = None
|
|
49
|
+
prompt: Optional[str] = None
|
|
50
|
+
vars: Dict[str, Any] = field(default_factory=dict)
|
|
51
|
+
recording_id: Optional[int] = None
|
|
52
|
+
#: Log events ``{"seq", "t", "level", "path", "msg"}``.
|
|
53
|
+
events: List[Dict[str, Any]] = field(default_factory=list)
|
|
54
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def active(self) -> bool:
|
|
58
|
+
return self.state in ACTIVE_STATES
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def ok(self) -> bool:
|
|
62
|
+
"""Finished successfully."""
|
|
63
|
+
return self.state == "finished"
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def last_seq(self) -> int:
|
|
67
|
+
return int(self.raw.get("last_seq") or 0)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def from_json(cls, d: Mapping[str, Any]) -> RunStatus:
|
|
71
|
+
flow = d.get("flow") if isinstance(d.get("flow"), dict) else {}
|
|
72
|
+
return cls(
|
|
73
|
+
id=d.get("id"),
|
|
74
|
+
state=str(d.get("state") or "idle"),
|
|
75
|
+
flow_name=str(flow.get("name") or ""),
|
|
76
|
+
error=d.get("error"),
|
|
77
|
+
prompt=d.get("prompt"),
|
|
78
|
+
vars=dict(d.get("vars") or {}),
|
|
79
|
+
recording_id=d.get("recording_id"),
|
|
80
|
+
events=[e for e in d.get("events") or [] if isinstance(e, dict)],
|
|
81
|
+
raw=dict(d),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def list_flows(t: Transport) -> List[Flow]:
|
|
86
|
+
return [Flow.from_json(d) for d in t.get("/automation/flows") or [] if isinstance(d, dict)]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_flow(t: Transport, flow_id: str) -> Dict[str, Any]:
|
|
90
|
+
return t.get(f"/automation/flows/{segment(flow_id)}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def run_status(t: Transport, after: int = 0) -> RunStatus:
|
|
94
|
+
return RunStatus.from_json(t.get("/automation/run", after=after))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def abort_run(t: Transport) -> None:
|
|
98
|
+
t.post("/automation/run/abort")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def continue_run(t: Transport) -> None:
|
|
102
|
+
t.post("/automation/run/continue")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def run_flow(
|
|
106
|
+
t: Transport,
|
|
107
|
+
flow: Union[str, Mapping[str, Any]],
|
|
108
|
+
*,
|
|
109
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
110
|
+
arm: bool = False,
|
|
111
|
+
wait: bool = True,
|
|
112
|
+
timeout: Optional[float] = None,
|
|
113
|
+
poll: float = 0.25,
|
|
114
|
+
on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
115
|
+
) -> RunStatus:
|
|
116
|
+
"""Start a run and (with ``wait``) follow it to its end; returns the final status."""
|
|
117
|
+
if isinstance(flow, str):
|
|
118
|
+
if not flow.strip():
|
|
119
|
+
raise ValueError("flow id must not be empty")
|
|
120
|
+
body: Dict[str, Any] = {"flow_id": flow}
|
|
121
|
+
elif isinstance(flow, Mapping):
|
|
122
|
+
body = {"flow": dict(flow)}
|
|
123
|
+
else:
|
|
124
|
+
raise TypeError("flow must be a saved flow id (str) or a flow document (dict)")
|
|
125
|
+
if params is not None:
|
|
126
|
+
if not isinstance(params, Mapping):
|
|
127
|
+
raise TypeError("params must be a dict")
|
|
128
|
+
body["params"] = dict(params)
|
|
129
|
+
if not isinstance(arm, bool):
|
|
130
|
+
raise TypeError("arm must be True or False")
|
|
131
|
+
if arm:
|
|
132
|
+
body["arm"] = True
|
|
133
|
+
if timeout is not None and timeout <= 0:
|
|
134
|
+
raise ValueError("timeout must be positive")
|
|
135
|
+
if poll <= 0:
|
|
136
|
+
raise ValueError("poll must be positive")
|
|
137
|
+
|
|
138
|
+
status = RunStatus.from_json(t.post("/automation/run", body))
|
|
139
|
+
if not wait:
|
|
140
|
+
return status
|
|
141
|
+
run_id, events, seq = status.id, list(status.events), status.last_seq
|
|
142
|
+
for e in events:
|
|
143
|
+
_emit(on_event, e)
|
|
144
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
145
|
+
try:
|
|
146
|
+
while status.active:
|
|
147
|
+
if deadline is not None and time.monotonic() > deadline:
|
|
148
|
+
_abort_quietly(t)
|
|
149
|
+
raise TimeoutError(f"the run did not end within {timeout} s: it was aborted (cleanup runs)")
|
|
150
|
+
time.sleep(poll)
|
|
151
|
+
fresh = run_status(t, after=seq)
|
|
152
|
+
if fresh.id != run_id:
|
|
153
|
+
break # another run replaced ours: ours has ended
|
|
154
|
+
for e in fresh.events:
|
|
155
|
+
events.append(e)
|
|
156
|
+
_emit(on_event, e)
|
|
157
|
+
seq = max(seq, fresh.last_seq)
|
|
158
|
+
status = fresh
|
|
159
|
+
except KeyboardInterrupt:
|
|
160
|
+
_abort_quietly(t)
|
|
161
|
+
raise
|
|
162
|
+
status.events = events
|
|
163
|
+
return status
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _emit(cb: Optional[Callable[[Dict[str, Any]], None]], e: Dict[str, Any]) -> None:
|
|
167
|
+
if cb is not None:
|
|
168
|
+
cb(e)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _abort_quietly(t: Transport) -> None:
|
|
172
|
+
with contextlib.suppress(Refused): # it ended in the meantime
|
|
173
|
+
abort_run(t)
|