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 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)