meshbench 0.0.5__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.
meshbench/__init__.py ADDED
@@ -0,0 +1,157 @@
1
+ """Drive a MeshBench workbench from Python.
2
+
3
+ from meshbench import Workbench
4
+
5
+ with Workbench.headless(fixture="fife-strict", seed=9001) as wb:
6
+ wb.sim.run(timedelta(minutes=5))
7
+ print(wb.provenance())
8
+ print(wb.events.total(), "events")
9
+
10
+ Two layers. `wb.call(verb, params)` is the whole API and stays public, so a
11
+ verb this package has not shaped is one line away rather than a blocker; above
12
+ it sit `wb.nodes`, `wb.sim`, `wb.firmware`, `wb.events`, `wb.project`,
13
+ `wb.live` and a node's own console.
14
+
15
+ Every wait is a method - `node.wait_running()`, `sim.run()`,
16
+ `firmware.wait_started()` - never a sleep in a script. They poll today and will
17
+ subscribe later, and no script changes when they do.
18
+
19
+ A wait measured in simulated time is not a wait measured in yours:
20
+ `sim.run(minutes=5)` is five minutes of the mesh's own clock, and on 155
21
+ emulated nodes that is a great deal longer than five of yours.
22
+ """
23
+
24
+ from ._socket import (
25
+ BINARY_ENV,
26
+ PROTOCOL,
27
+ RENDEZVOUS_ENV,
28
+ SOCKET_ENV,
29
+ default_address,
30
+ default_socket_path,
31
+ )
32
+ from .boundary import Boundary
33
+ from .checks import Assertions, Check, Report, Schedule
34
+ from .device import Device
35
+ from .errors import (
36
+ BadParams,
37
+ Closing,
38
+ Conflict,
39
+ MeshbenchError,
40
+ NotFound,
41
+ ProtocolMismatch,
42
+ Refused,
43
+ Timeout,
44
+ Unavailable,
45
+ UnknownVerb,
46
+ VersionMismatch,
47
+ )
48
+ from .live import DEFAULT_WINDOW, Live
49
+ from .nodes import Node, Nodes
50
+ from .pairing import paired_release, pairing_note, release
51
+ from .parts import Console, Events, Firmware, Job, Project, Sim
52
+ from .sessions import SESSIONS_ENV, Session, sessions, sessions_dir
53
+ from .sets import (
54
+ DEFAULT_PRESET,
55
+ Board,
56
+ Class,
57
+ Kind,
58
+ Preset,
59
+ Role,
60
+ Strategy,
61
+ Tab,
62
+ Transport,
63
+ )
64
+ from .subscribe import Notification, Subscription, subscribe
65
+ from .types import (
66
+ Aimed,
67
+ Antenna,
68
+ Build,
69
+ BuildDetails,
70
+ CardSlot,
71
+ Event,
72
+ Hello,
73
+ ImportPreview,
74
+ JobInfo,
75
+ NameMatch,
76
+ NodeInfo,
77
+ NodeStat,
78
+ Provenance,
79
+ Screen,
80
+ Shot,
81
+ SimState,
82
+ )
83
+ from .workbench import Workbench
84
+
85
+ __all__ = [
86
+ "SESSIONS_ENV",
87
+ "Session",
88
+ "sessions",
89
+ "sessions_dir",
90
+ "Schedule",
91
+ "BINARY_ENV",
92
+ "Boundary",
93
+ "Transport",
94
+ "Tab",
95
+ "Strategy",
96
+ "Role",
97
+ "Class",
98
+ "Live",
99
+ "DEFAULT_WINDOW",
100
+ "ImportPreview",
101
+ "NameMatch",
102
+ "Report",
103
+ "Preset",
104
+ "Kind",
105
+ "DEFAULT_PRESET",
106
+ "Check",
107
+ "Board",
108
+ "Device",
109
+ "Assertions",
110
+ "PROTOCOL",
111
+ "RENDEZVOUS_ENV",
112
+ "SOCKET_ENV",
113
+ "BadParams",
114
+ "Build",
115
+ "BuildDetails",
116
+ "Aimed",
117
+ "Antenna",
118
+ "CardSlot",
119
+ "Closing",
120
+ "Conflict",
121
+ "Console",
122
+ "Event",
123
+ "Events",
124
+ "Firmware",
125
+ "Hello",
126
+ "Job",
127
+ "JobInfo",
128
+ "MeshbenchError",
129
+ "Node",
130
+ "NodeInfo",
131
+ "NodeStat",
132
+ "Nodes",
133
+ "NotFound",
134
+ "Project",
135
+ "ProtocolMismatch",
136
+ "Provenance",
137
+ "Refused",
138
+ "Sim",
139
+ "Screen",
140
+ "Shot",
141
+ "SimState",
142
+ "Timeout",
143
+ "Unavailable",
144
+ "UnknownVerb",
145
+ "VersionMismatch",
146
+ "paired_release",
147
+ "pairing_note",
148
+ "release",
149
+ "Notification",
150
+ "Subscription",
151
+ "subscribe",
152
+ "Workbench",
153
+ "default_address",
154
+ "default_socket_path",
155
+ ]
156
+
157
+ __version__ = "0.0.5"
meshbench/_socket.py ADDED
@@ -0,0 +1,247 @@
1
+ """The wire: one JSON request per line, one reply.
2
+
3
+ Everything above this is shape. This is the whole protocol, and it is small on
4
+ purpose - a client that needed a framework to speak to a local socket would be
5
+ a client nobody could debug.
6
+
7
+ Two transports, because one does not travel:
8
+
9
+ - A **unix socket**, where the operating system has one. The filesystem is the
10
+ access control, and the kernel enforces it.
11
+ - **Loopback TCP with a token**, where it does not. Windows is the case:
12
+ CPython has never exposed ``socket.AF_UNIX`` there, so a unix socket is not
13
+ merely awkward from Python on Windows, it is unreachable. The workbench binds
14
+ 127.0.0.1 on an ephemeral port and writes the address and a 128-bit token to
15
+ a 0600 file; this reads that file and presents the token before anything
16
+ else. Any local process can open a loopback port, so the token is what stands
17
+ where the kernel stood.
18
+
19
+ The choice is by operating system, not by language, so the Go client and this
20
+ one always speak the same thing on the same machine.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import contextlib
26
+ import json
27
+ import os
28
+ import socket
29
+ import sys
30
+ import threading
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ from .pairing import release
35
+
36
+ #: Chooses where the workbench answers: a path, or "tcp", or "tcp:host:port".
37
+ SOCKET_ENV = "MESHBENCH_CONTROL_SOCKET"
38
+
39
+ #: What to run when a client is asked to start a workbench and nothing named a
40
+ #: binary. A checkout has one built but not installed, and every example and
41
+ #: every test then needs the same three lines to find it - so the variable the
42
+ #: test harness already used is honoured by the clients too.
43
+ BINARY_ENV = "MESHBENCH_BINARY"
44
+
45
+ #: Chooses the file a TCP listener writes its address and token to. Per user by
46
+ #: default, which is wrong for two runs at once - the second would overwrite the
47
+ #: first's - so a client that starts a workbench gives it one of its own.
48
+ RENDEZVOUS_ENV = "MESHBENCH_CONTROL_RENDEZVOUS"
49
+
50
+ #: The wire version this client speaks. A workbench answering anything else is
51
+ #: refused at connect rather than halfway through a script.
52
+ PROTOCOL = 1
53
+
54
+ #: The shortest sun_path any platform we run on allows: 108 on Linux, 104 on
55
+ #: macOS and the BSDs.
56
+ MAX_UNIX_PATH = 104
57
+
58
+ #: Whether this Python can speak to a unix socket at all.
59
+ HAVE_AF_UNIX = hasattr(socket, "AF_UNIX")
60
+
61
+
62
+ def _cache_dir() -> Path:
63
+ """The per-user directory this OS already defines.
64
+
65
+ What os.getuid() was standing in for, less portably - it does not exist on
66
+ Windows at all, so the old default crashed there before it could be wrong.
67
+ """
68
+ if sys.platform == "win32":
69
+ base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
70
+ elif sys.platform == "darwin":
71
+ base = os.path.expanduser("~/Library/Caches")
72
+ else:
73
+ base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache")
74
+ d = Path(base) / "meshbench"
75
+ d.mkdir(parents=True, exist_ok=True)
76
+ return d
77
+
78
+
79
+ def rendezvous_path() -> Path:
80
+ """Where a TCP listener leaves its address and token."""
81
+ named = os.environ.get(RENDEZVOUS_ENV)
82
+ if named:
83
+ p = Path(named)
84
+ p.parent.mkdir(parents=True, exist_ok=True)
85
+ return p
86
+ return _cache_dir() / "control.json"
87
+
88
+
89
+ def default_address() -> str:
90
+ """Where a workbench answers on this operating system unless told otherwise."""
91
+ env = os.environ.get(SOCKET_ENV)
92
+ if env:
93
+ return env
94
+ if sys.platform == "win32" or not HAVE_AF_UNIX:
95
+ return "tcp"
96
+ # Linux keeps exactly the path it has always had: scripts
97
+ # and tools/soak all name it, and moving it would break them for no gain.
98
+ runtime = os.environ.get("XDG_RUNTIME_DIR")
99
+ if runtime:
100
+ return os.path.join(runtime, "meshbench.sock")
101
+ # Everywhere else, the per-user cache directory - which on macOS is also
102
+ # short enough to stay inside sun_path, where $TMPDIR would not be.
103
+ return str(_cache_dir() / "control.sock")
104
+
105
+
106
+ #: Kept for callers written against the old name, which meant the same thing
107
+ #: while a unix socket was the only thing there was.
108
+ default_socket_path = default_address
109
+
110
+
111
+ def _read_rendezvous() -> tuple[str, str]:
112
+ path = rendezvous_path()
113
+ try:
114
+ got = json.loads(path.read_text())
115
+ except OSError as e:
116
+ raise ConnectionError(f"no workbench has left an address at {path}: {e}") from e
117
+ except ValueError as e:
118
+ raise ConnectionError(f"{path} is not readable as an address: {e}") from e
119
+ return got.get("address", ""), got.get("token", "")
120
+
121
+
122
+ def _connect(
123
+ address: str, timeout: float | None, token: str = ""
124
+ ) -> tuple[socket.socket, str]:
125
+ """Open the socket the address names, and return it with any token.
126
+
127
+ A caller that already holds the token says so. That is how a row from
128
+ sessions() is connected to: two TCP workbenches share one per-user
129
+ rendezvous file and the second overwrites the first, so the token beside
130
+ the address in the session's own file is the only one that is certainly
131
+ its own.
132
+ """
133
+ if address == "tcp" or address.startswith("tcp:"):
134
+ if address == "tcp":
135
+ host_port, found = _read_rendezvous()
136
+ token = token or found
137
+ else:
138
+ host_port = address[len("tcp:") :]
139
+ if ":" not in host_port:
140
+ host_port = "127.0.0.1:" + host_port
141
+ # A port somebody named still needs the token, and without one in
142
+ # hand the rendezvous file is the only place it exists.
143
+ if not token:
144
+ _, token = _read_rendezvous()
145
+ host, _, port = host_port.rpartition(":")
146
+ s = socket.create_connection((host or "127.0.0.1", int(port)), timeout=timeout)
147
+ return s, token
148
+
149
+ path = address[len("unix:") :] if address.startswith("unix:") else address
150
+ if not HAVE_AF_UNIX:
151
+ raise ConnectionError(
152
+ f"{sys.platform} has no unix socket this Python can open, and "
153
+ f"{path} is one. Start the workbench with -control-socket tcp"
154
+ )
155
+ if len(path) > MAX_UNIX_PATH:
156
+ raise ConnectionError(
157
+ f"{path} is {len(path)} bytes and a unix socket path may be at "
158
+ f"most {MAX_UNIX_PATH} - choose a shorter one, or use tcp"
159
+ )
160
+ s = socket.socket(socket.AF_UNIX)
161
+ s.settimeout(timeout)
162
+ s.connect(path)
163
+ return s, ""
164
+
165
+
166
+ class Connection:
167
+ """One socket, and the lock that keeps two threads from interleaving.
168
+
169
+ The protocol has request ids but the workbench answers in order, so the
170
+ simplest correct thing is one call at a time. A client that pipelined would
171
+ have to demultiplex, and nothing here needs the throughput.
172
+ """
173
+
174
+ def __init__(
175
+ self,
176
+ address: str | None = None,
177
+ timeout: float | None = 300.0,
178
+ token: str = "",
179
+ ):
180
+ self.address = address or default_address()
181
+ self._sock, token = _connect(self.address, timeout, token)
182
+ self._file = self._sock.makefile("rb")
183
+ self._lock = threading.Lock()
184
+ self._next_id = 0
185
+ if token:
186
+ # The token first, before anything else on the wire. A loopback
187
+ # port is reachable by any local process, so this is what stands in
188
+ # for the permissions a unix socket would have had. The wire
189
+ # version rides along, so a workbench that cannot speak to this
190
+ # client says so at the door, and the release beside it so one that
191
+ # was never meant to be driven by this client says so too.
192
+ line = {"token": token, "protocol": PROTOCOL, "release": release()}
193
+ self._sock.sendall((json.dumps(line) + "\n").encode())
194
+
195
+ def call(self, verb: str, params: Any = None) -> dict[str, Any]:
196
+ """Send one verb and return the whole reply, errors included."""
197
+ with self._lock:
198
+ self._next_id += 1
199
+ req: dict[str, Any] = {"id": self._next_id, "method": verb}
200
+ if self._next_id == 1:
201
+ # A unix socket has no line of its own to declare the wire
202
+ # version on, so it goes on the first request: refused there,
203
+ # before any verb runs, rather than found out from a verb
204
+ # behaving oddly. Only the first, because the answer cannot
205
+ # change while the connection is open. The release travels with
206
+ # it: a client and the workbench it drives must be the same one.
207
+ req["protocol"] = PROTOCOL
208
+ req["release"] = release()
209
+ if params is not None:
210
+ req["params"] = params
211
+ self._sock.sendall((json.dumps(req) + "\n").encode())
212
+ line = self._file.readline()
213
+ if not line:
214
+ raise ConnectionError(
215
+ f"the workbench at {self.address} closed the connection"
216
+ )
217
+ return json.loads(line.decode())
218
+
219
+ def shutdown(self) -> None:
220
+ """Break a read in progress so a streaming reader on another thread can
221
+ be closed. A plain close would deadlock: readline holds the buffer's
222
+ lock while it blocks on the socket, and close wants that same lock. A
223
+ socket shutdown makes the blocked read return instead, and then close is
224
+ uncontended. Errors are ignored - the socket may already be gone.
225
+ """
226
+ with contextlib.suppress(OSError):
227
+ self._sock.shutdown(socket.SHUT_RDWR)
228
+
229
+ def close(self) -> None:
230
+ try:
231
+ self._file.close()
232
+ finally:
233
+ self._sock.close()
234
+
235
+
236
+ def is_live(address: str, token: str = "") -> bool:
237
+ """Whether something is already answering there.
238
+
239
+ A connect rather than a stat: a socket file existing says nothing about
240
+ whether anybody is behind it, and that difference is the whole question.
241
+ """
242
+ try:
243
+ s, _ = _connect(address, 0.25, token)
244
+ s.close()
245
+ return True
246
+ except (OSError, ConnectionError, ValueError):
247
+ return False
meshbench/boundary.py ADDED
@@ -0,0 +1,124 @@
1
+ """The study area: which nodes are in the question being asked.
2
+
3
+ Not the firmware's region concept. A boundary decides what is *studied*; a
4
+ region decides what is *forwarded*. Both words are in this application and
5
+ confusing them is how somebody concludes the RF model is broken.
6
+
7
+ Set it before importing. The import filters at fetch time, so a boundary set
8
+ afterwards prunes what has already been paid for rather than never fetching it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING
16
+
17
+ from . import errors
18
+
19
+ if TYPE_CHECKING: # pragma: no cover - import for typing only
20
+ from .workbench import Workbench
21
+
22
+
23
+ class Boundary:
24
+ """The study area, however you have it. Live."""
25
+
26
+ def __init__(self, wb: Workbench) -> None:
27
+ self._wb = wb
28
+
29
+ def use(self, area: str | Path, name: str = "") -> list[str]:
30
+ """Take a study area from a place name or from GeoJSON.
31
+
32
+ The one to call. A path to a .geojson file is loaded; anything else is
33
+ searched for by name and the best match accepted. Both end with the
34
+ area in the study, which is the only thing the caller wanted to say.
35
+
36
+ ``name`` renames a single loaded polygon, so a file called
37
+ ``export(3).geojson`` can still join the study as "Tay catchment".
38
+ """
39
+ if _is_a_file(area):
40
+ return self.load(area, name=name)
41
+ return [self.accept(self.search(str(area))[0])]
42
+
43
+ def search(self, query: str) -> list[str]:
44
+ """Places matching a name, best first. Needs the network.
45
+
46
+ Returns names rather than geometry: the geometry stays at the
47
+ workbench, and the name is what accept takes.
48
+ """
49
+ got = self._wb.call("boundary.set", {"query": query}) or {}
50
+ found = got.get("names") or []
51
+ if not found:
52
+ raise errors.NotFound(
53
+ "boundary.set", f"nothing is called {query!r}", "not_found"
54
+ )
55
+ return found
56
+
57
+ def accept(self, name: str) -> str:
58
+ """Take one of the search results into the study area.
59
+
60
+ Areas union rather than replace: a study is often two council areas
61
+ rather than one.
62
+ """
63
+ got = self._wb.call("boundary.accept", {"name": name}) or {}
64
+ return got.get("accepted", name)
65
+
66
+ def load(self, source: str | Path | dict, name: str = "") -> list[str]:
67
+ """Take a study area from GeoJSON: a path, a document, or a dict.
68
+
69
+ A Polygon, a MultiPolygon, a Feature or a FeatureCollection. Each
70
+ polygon becomes an area named from its ``name`` property, or from
71
+ ``name``, or from the file.
72
+
73
+ The one way to study an area nothing has an administrative name for -
74
+ a catchment, a valley, the bit north of the river - and the only one
75
+ that works with no network at all.
76
+ """
77
+ params: dict = {}
78
+ if isinstance(source, dict):
79
+ params["geojson"] = json.dumps(source)
80
+ elif _is_a_file(source):
81
+ params["path"] = str(source)
82
+ else:
83
+ params["geojson"] = str(source)
84
+ if name:
85
+ params["name"] = name
86
+ got = self._wb.call("boundary.load", params) or {}
87
+ return got.get("loaded") or []
88
+
89
+ def list(self) -> list[str]:
90
+ """What the study area is made of."""
91
+ return (self._wb.call("boundary.list") or {}).get("names") or []
92
+
93
+ def remove(self, name: str) -> None:
94
+ """Take one area back out.
95
+
96
+ Changes what is measured, never what is loaded: the nodes stay until
97
+ something prunes them.
98
+ """
99
+ self._wb.call("boundary.remove", {"name": name})
100
+
101
+ def prune(self, margin_km: float | None = None) -> int:
102
+ """Delete the nodes outside the study area, and say how many went.
103
+
104
+ For a mesh that was imported before the boundary was set. The margin is
105
+ kept on purpose: a node just outside still interferes with one just
106
+ inside, and dropping it makes the inside look quieter than it is.
107
+ """
108
+ params = {} if margin_km is None else {"margin_km": margin_km}
109
+ return (self._wb.call("boundary.prune", params) or {}).get("removed", 0)
110
+
111
+
112
+ def _is_a_file(x: object) -> bool:
113
+ """A path, rather than a place name or a GeoJSON document.
114
+
115
+ Judged by extension as well as by existence, so a mistyped path is reported
116
+ as a missing file rather than searched for as a place - which answers
117
+ "nothing is called ./bounds/fife.geojson" and sends the reader looking in
118
+ entirely the wrong direction.
119
+ """
120
+ if isinstance(x, Path):
121
+ return True
122
+ if not isinstance(x, str) or x.lstrip().startswith("{"):
123
+ return False
124
+ return x.endswith((".geojson", ".json")) or Path(x).is_file()