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 +157 -0
- meshbench/_socket.py +247 -0
- meshbench/boundary.py +124 -0
- meshbench/checks.py +225 -0
- meshbench/device.py +99 -0
- meshbench/errors.py +153 -0
- meshbench/live.py +122 -0
- meshbench/nodes.py +481 -0
- meshbench/pairing.py +70 -0
- meshbench/parts.py +669 -0
- meshbench/pytest_plugin.py +118 -0
- meshbench/sessions.py +159 -0
- meshbench/sets.py +339 -0
- meshbench/subscribe.py +90 -0
- meshbench/types.py +508 -0
- meshbench/wait.py +110 -0
- meshbench/workbench.py +560 -0
- meshbench-0.0.5.dist-info/METADATA +124 -0
- meshbench-0.0.5.dist-info/RECORD +21 -0
- meshbench-0.0.5.dist-info/WHEEL +4 -0
- meshbench-0.0.5.dist-info/entry_points.txt +2 -0
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()
|