fieldsim 0.2.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.
fieldsim/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """fieldsim - virtual industrial devices for testing software without hardware."""
2
+
3
+ from fieldsim.background import BackgroundSimulator, DeviceHandle, FaultHandle
4
+ from fieldsim.config import ConfigError, SimulationConfig, load_config
5
+ from fieldsim.core.clock import RealClock, VirtualClock
6
+ from fieldsim.core.device import Device
7
+ from fieldsim.core.faults import ActiveFault
8
+ from fieldsim.simulator import Simulator, create_device
9
+
10
+ __version__ = "0.2.0"
11
+
12
+ __all__ = [
13
+ "ActiveFault",
14
+ "BackgroundSimulator",
15
+ "ConfigError",
16
+ "Device",
17
+ "DeviceHandle",
18
+ "FaultHandle",
19
+ "RealClock",
20
+ "SimulationConfig",
21
+ "Simulator",
22
+ "VirtualClock",
23
+ "__version__",
24
+ "create_device",
25
+ "load_config",
26
+ ]
fieldsim/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from fieldsim.cli import app
2
+
3
+ app(prog_name="fieldsim")
fieldsim/background.py ADDED
@@ -0,0 +1,362 @@
1
+ """Run a simulation in a background thread and control it from synchronous code.
2
+
3
+ The devices live on their own event loop in a daemon thread, so they work the
4
+ same whether the code under test is synchronous, asyncio, trio or a separate
5
+ process. Every method of :class:`DeviceHandle` is thread-safe.
6
+
7
+ ::
8
+
9
+ with BackgroundSimulator("plant.yaml") as sim:
10
+ plc = sim["boiler-plc"]
11
+ plc.set("temperature", 85.0)
12
+ with plc.fault("latency", delay="300ms"):
13
+ run_my_gateway(plc.host, plc.port)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import inspect
20
+ import threading
21
+ from collections.abc import Callable, Iterator, Mapping
22
+ from contextlib import contextmanager
23
+ from pathlib import Path
24
+ from types import TracebackType
25
+ from typing import Any, Literal, TypeVar
26
+
27
+ from fieldsim.config import SimulationConfig, load_config, parse_config
28
+ from fieldsim.core.clock import Clock, RealClock, VirtualClock
29
+ from fieldsim.core.datastore import Value
30
+ from fieldsim.core.device import Device
31
+ from fieldsim.core.faults import ActiveFault
32
+ from fieldsim.protocols.modbus.datastore import WriteEvent
33
+ from fieldsim.protocols.modbus.device import ModbusDevice
34
+ from fieldsim.simulator import Simulator
35
+
36
+ T = TypeVar("T")
37
+
38
+ ConfigSource = SimulationConfig | Mapping[str, Any] | str | Path
39
+ ClockKind = Literal["real", "virtual"]
40
+
41
+ CALL_TIMEOUT = 10.0
42
+
43
+
44
+ def resolve_config(source: ConfigSource) -> SimulationConfig:
45
+ """Accept a config object, a dict or a path to a YAML file."""
46
+ if isinstance(source, SimulationConfig):
47
+ return source
48
+ if isinstance(source, str | Path):
49
+ return load_config(source)
50
+ return parse_config(dict(source))
51
+
52
+
53
+ def with_free_ports(config: SimulationConfig) -> SimulationConfig:
54
+ """Copy of ``config`` where every device listens on an OS-assigned port.
55
+
56
+ Lets tests run in parallel (pytest-xdist) without port clashes.
57
+ """
58
+ devices = [device.model_copy(update={"port": 0}) for device in config.devices]
59
+ return config.model_copy(update={"devices": devices})
60
+
61
+
62
+ class BackgroundSimulator:
63
+ def __init__(
64
+ self,
65
+ config: ConfigSource,
66
+ *,
67
+ clock: ClockKind = "real",
68
+ free_ports: bool = False,
69
+ ):
70
+ if clock not in ("real", "virtual"):
71
+ raise ValueError(f"clock must be 'real' or 'virtual', not {clock!r}")
72
+ resolved = resolve_config(config)
73
+ self.config = with_free_ports(resolved) if free_ports else resolved
74
+ self.clock_kind: ClockKind = clock
75
+ self._clock: Clock = VirtualClock() if clock == "virtual" else RealClock()
76
+ self._loop: asyncio.AbstractEventLoop | None = None
77
+ self._thread: threading.Thread | None = None
78
+ self._stop_event: asyncio.Event | None = None
79
+ self._error: BaseException | None = None
80
+ self._handles: dict[str, DeviceHandle] = {}
81
+
82
+ # ------------------------------------------------------------------ lifecycle
83
+
84
+ @property
85
+ def running(self) -> bool:
86
+ return self._thread is not None and self._thread.is_alive()
87
+
88
+ def start(self, timeout: float = CALL_TIMEOUT) -> None:
89
+ if self._thread is not None:
90
+ raise RuntimeError("simulator already started")
91
+ ready = threading.Event()
92
+ self._error = None
93
+ self._thread = threading.Thread(
94
+ target=self._thread_main, args=(ready,), name="fieldsim", daemon=True
95
+ )
96
+ self._thread.start()
97
+ if not ready.wait(timeout): # pragma: no cover - only on a wedged machine
98
+ raise TimeoutError("simulator did not start in time")
99
+ if self._error is not None:
100
+ self._thread.join()
101
+ self._thread = None
102
+ raise self._error
103
+
104
+ def stop(self, timeout: float = CALL_TIMEOUT) -> None:
105
+ thread, self._thread = self._thread, None
106
+ if thread is None:
107
+ return
108
+ if self._loop is not None and self._stop_event is not None:
109
+ self._loop.call_soon_threadsafe(self._stop_event.set)
110
+ thread.join(timeout)
111
+ self._handles = {}
112
+
113
+ def __enter__(self) -> BackgroundSimulator:
114
+ self.start()
115
+ return self
116
+
117
+ def __exit__(
118
+ self,
119
+ exc_type: type[BaseException] | None,
120
+ exc: BaseException | None,
121
+ tb: TracebackType | None,
122
+ ) -> None:
123
+ self.stop()
124
+
125
+ def _thread_main(self, ready: threading.Event) -> None:
126
+ loop = asyncio.new_event_loop()
127
+ self._loop = loop
128
+ try:
129
+ loop.run_until_complete(self._serve(ready))
130
+ finally:
131
+ loop.close()
132
+
133
+ async def _serve(self, ready: threading.Event) -> None:
134
+ self._stop_event = asyncio.Event()
135
+ try:
136
+ sim = Simulator(self.config, clock=self._clock)
137
+ await sim.start()
138
+ except BaseException as exc:
139
+ self._error = exc
140
+ ready.set()
141
+ return
142
+ self._handles = {name: DeviceHandle(self, dev) for name, dev in sim.devices.items()}
143
+ ready.set()
144
+ try:
145
+ await self._stop_event.wait()
146
+ finally:
147
+ await sim.stop()
148
+
149
+ # ------------------------------------------------------------------ access
150
+
151
+ @property
152
+ def devices(self) -> dict[str, DeviceHandle]:
153
+ return dict(self._handles)
154
+
155
+ def __getitem__(self, name: str) -> DeviceHandle:
156
+ try:
157
+ return self._handles[name]
158
+ except KeyError:
159
+ available = ", ".join(self._handles) or "none (simulator not running)"
160
+ raise KeyError(f"no device named {name!r}; available: {available}") from None
161
+
162
+ def __iter__(self) -> Iterator[DeviceHandle]:
163
+ return iter(list(self._handles.values()))
164
+
165
+ def call(
166
+ self, fn: Callable[..., Any], *args: Any, timeout: float = CALL_TIMEOUT, **kwargs: Any
167
+ ) -> Any:
168
+ """Run ``fn`` on the simulator's event loop and return its result.
169
+
170
+ Coroutine results are awaited. Exceptions are re-raised in the caller.
171
+ """
172
+ loop = self._loop
173
+ if loop is None or not self.running:
174
+ raise RuntimeError("simulator is not running")
175
+ if threading.current_thread() is self._thread:
176
+ raise RuntimeError("call() would deadlock: already on the simulator thread")
177
+
178
+ async def runner() -> Any:
179
+ result = fn(*args, **kwargs)
180
+ if inspect.isawaitable(result):
181
+ result = await result
182
+ return result
183
+
184
+ return asyncio.run_coroutine_threadsafe(runner(), loop).result(timeout)
185
+
186
+ # ------------------------------------------------------------------ time
187
+
188
+ def time(self) -> float:
189
+ """Current simulation time (seconds; virtual time starts at 0)."""
190
+ return float(self.call(self._clock.time))
191
+
192
+ def advance(self, seconds: float) -> None:
193
+ """Move the virtual clock forward, running everything that falls due."""
194
+ if not isinstance(self._clock, VirtualClock):
195
+ raise RuntimeError("advance() needs a virtual clock: use clock='virtual'")
196
+ self.call(self._clock.advance, seconds, timeout=CALL_TIMEOUT + seconds / 100)
197
+
198
+ async def advance_async(self, seconds: float) -> None:
199
+ """:meth:`advance` for async tests: does not block the caller's event loop."""
200
+ await asyncio.to_thread(self.advance, seconds)
201
+
202
+
203
+ class FaultHandle:
204
+ """Thread-safe view of an :class:`~fieldsim.core.faults.ActiveFault`."""
205
+
206
+ def __init__(self, sim: BackgroundSimulator, fault: ActiveFault):
207
+ self._sim = sim
208
+ self._fault = fault
209
+
210
+ @property
211
+ def kind(self) -> str:
212
+ """Fault type, e.g. ``"latency"``."""
213
+ return self._fault.kind
214
+
215
+ @property
216
+ def hits(self) -> int:
217
+ """How many requests this fault affected so far."""
218
+ return int(self._sim.call(lambda: self._fault.hits))
219
+
220
+ @property
221
+ def active(self) -> bool:
222
+ return bool(self._sim.call(lambda: self._fault.active))
223
+
224
+ def remove(self) -> None:
225
+ if self._sim.running:
226
+ self._sim.call(self._fault.remove)
227
+
228
+ def __enter__(self) -> FaultHandle:
229
+ return self
230
+
231
+ def __exit__(
232
+ self,
233
+ exc_type: type[BaseException] | None,
234
+ exc: BaseException | None,
235
+ tb: TracebackType | None,
236
+ ) -> None:
237
+ self.remove()
238
+
239
+ def __repr__(self) -> str:
240
+ return f"<FaultHandle {self.kind}>"
241
+
242
+
243
+ class DeviceHandle:
244
+ """Thread-safe remote control for one device of a :class:`BackgroundSimulator`."""
245
+
246
+ def __init__(self, sim: BackgroundSimulator, device: Device[Any]):
247
+ self.simulator = sim
248
+ #: The underlying device. Only touch it from the simulator thread.
249
+ self.device = device
250
+ self.name = device.name
251
+ self.protocol = device.protocol
252
+
253
+ # Connection details are fixed once the device is running, so plain reads are safe.
254
+ @property
255
+ def host(self) -> str:
256
+ return self.device.host
257
+
258
+ @property
259
+ def port(self) -> int:
260
+ return self.device.port
261
+
262
+ @property
263
+ def address(self) -> tuple[str, int]:
264
+ return self.device.address
265
+
266
+ @property
267
+ def unit_id(self) -> int:
268
+ return int(getattr(self.device, "unit_id", 1))
269
+
270
+ def _call(self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
271
+ return self.simulator.call(fn, *args, **kwargs) # type: ignore[no-any-return]
272
+
273
+ # ------------------------------------------------------------------ values
274
+
275
+ def get(self, name: str) -> Value:
276
+ return self._call(self.device.get, name)
277
+
278
+ def set(self, name: str, value: Value) -> None:
279
+ self._call(self.device.set, name, value)
280
+
281
+ def release(self, name: str) -> None:
282
+ self._call(self.device.release, name)
283
+
284
+ # ------------------------------------------------------------------ faults
285
+
286
+ def fault(self, fault_type: str, **params: Any) -> FaultHandle:
287
+ """Activate a fault; use the result as a context manager to scope it."""
288
+ active = self._call(self.device.fault, fault_type, **params)
289
+ return FaultHandle(self.simulator, active)
290
+
291
+ def clear_faults(self) -> None:
292
+ self._call(self.device.clear_faults)
293
+
294
+ # ------------------------------------------------------------------ outages
295
+
296
+ @property
297
+ def online(self) -> bool:
298
+ return bool(self._call(lambda: self.device.online))
299
+
300
+ def go_offline(self) -> None:
301
+ self.simulator.call(self.device.go_offline)
302
+
303
+ def go_online(self) -> None:
304
+ self.simulator.call(self.device.go_online)
305
+
306
+ @contextmanager
307
+ def offline(self) -> Iterator[None]:
308
+ """``with device.offline(): ...`` - outage for the duration of the block."""
309
+ self.go_offline()
310
+ try:
311
+ yield
312
+ finally:
313
+ if self.simulator.running:
314
+ self.go_online()
315
+
316
+ # ------------------------------------------------------------------ writes
317
+
318
+ def _modbus(self) -> ModbusDevice:
319
+ if not isinstance(self.device, ModbusDevice): # pragma: no cover - only Modbus so far
320
+ raise TypeError(f"{self.protocol} devices do not record writes")
321
+ return self.device
322
+
323
+ @property
324
+ def writes(self) -> list[WriteEvent]:
325
+ """Snapshot of every client write recorded so far."""
326
+ device = self._modbus()
327
+ return self._call(lambda: list(device.writes))
328
+
329
+ def clear_writes(self) -> None:
330
+ self._call(self._modbus().clear_writes)
331
+
332
+ def wait_for_write(self, point: str | None = None, timeout: float = 5.0) -> WriteEvent:
333
+ """Block until the next client write (touching ``point``).
334
+
335
+ See :meth:`fieldsim.protocols.modbus.ModbusDevice.wait_for_write`.
336
+ """
337
+ result: WriteEvent = self.simulator.call(
338
+ self._modbus().wait_for_write, point, timeout, timeout=timeout + 5
339
+ )
340
+ return result
341
+
342
+ async def wait_for_write_async(
343
+ self, point: str | None = None, timeout: float = 5.0
344
+ ) -> WriteEvent:
345
+ """:meth:`wait_for_write` for async tests: does not block the caller's event loop."""
346
+ return await asyncio.to_thread(self.wait_for_write, point, timeout)
347
+
348
+ # ------------------------------------------------------------------ scenario
349
+
350
+ def advance(self, seconds: float) -> None:
351
+ """Shortcut for ``simulator.advance`` (the clock is shared by all devices)."""
352
+ self.simulator.advance(seconds)
353
+
354
+ async def advance_async(self, seconds: float) -> None:
355
+ await self.simulator.advance_async(seconds)
356
+
357
+ @property
358
+ def scenario_done(self) -> bool:
359
+ return bool(self._call(lambda: self.device.scenario.done))
360
+
361
+ def __repr__(self) -> str:
362
+ return f"<DeviceHandle {self.name!r} {self.protocol} {self.host}:{self.port}>"
fieldsim/cli.py ADDED
@@ -0,0 +1,137 @@
1
+ """Command line interface: ``fieldsim run | validate | schema``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from pathlib import Path
9
+ from typing import Annotated, Optional
10
+
11
+ import typer
12
+
13
+ from fieldsim import __version__
14
+ from fieldsim.config import ConfigError, SimulationConfig, load_config
15
+ from fieldsim.simulator import Simulator
16
+
17
+ app = typer.Typer(
18
+ name="fieldsim",
19
+ help="Virtual industrial devices for testing software without hardware.",
20
+ no_args_is_help=True,
21
+ add_completion=False,
22
+ pretty_exceptions_enable=False,
23
+ )
24
+
25
+ FileArg = Annotated[
26
+ Path, typer.Argument(exists=False, dir_okay=False, help="Simulation YAML file.")
27
+ ]
28
+
29
+
30
+ def _version(value: bool) -> None:
31
+ if value:
32
+ typer.echo(f"fieldsim {__version__}")
33
+ raise typer.Exit()
34
+
35
+
36
+ @app.callback()
37
+ def main(
38
+ version: Annotated[
39
+ Optional[bool], # noqa: UP045 - typer needs Optional on 3.10
40
+ typer.Option("--version", callback=_version, is_eager=True, help="Show version."),
41
+ ] = None,
42
+ ) -> None:
43
+ """fieldsim command line."""
44
+
45
+
46
+ def _load(path: Path) -> SimulationConfig:
47
+ try:
48
+ return load_config(path)
49
+ except ConfigError as exc:
50
+ typer.echo(f"ERROR {exc}", err=True)
51
+ raise typer.Exit(code=1) from None
52
+
53
+
54
+ def _describe(config: SimulationConfig) -> str:
55
+ devices = len(config.devices)
56
+ points = sum(len(list(d.registers.all_points())) for d in config.devices)
57
+ return f"{devices} device{'s' * (devices != 1)}, {points} point{'s' * (points != 1)}"
58
+
59
+
60
+ @app.command()
61
+ def validate(
62
+ files: Annotated[list[Path], typer.Argument(help="One or more simulation files.")],
63
+ ) -> None:
64
+ """Check simulation files without starting anything."""
65
+ failed = False
66
+ for path in files:
67
+ try:
68
+ config = load_config(path)
69
+ except ConfigError as exc:
70
+ typer.echo(f"ERROR {exc}", err=True)
71
+ failed = True
72
+ else:
73
+ typer.echo(f"OK {path}: {_describe(config)}")
74
+ if failed:
75
+ raise typer.Exit(code=1)
76
+
77
+
78
+ @app.command()
79
+ def schema(
80
+ output: Annotated[
81
+ Optional[Path], # noqa: UP045
82
+ typer.Option("--output", "-o", help="Write to a file instead of stdout."),
83
+ ] = None,
84
+ ) -> None:
85
+ """Print the JSON Schema of the simulation file format (for editor autocompletion)."""
86
+ document = SimulationConfig.model_json_schema()
87
+ document["$schema"] = "https://json-schema.org/draft/2020-12/schema"
88
+ document["title"] = "fieldsim simulation file"
89
+ text = json.dumps(document, indent=2)
90
+ if output is None:
91
+ typer.echo(text)
92
+ else:
93
+ output.write_text(text + "\n", encoding="utf-8")
94
+ typer.echo(f"wrote {output}")
95
+
96
+
97
+ @app.command()
98
+ def run(
99
+ file: FileArg,
100
+ duration: Annotated[
101
+ Optional[float], # noqa: UP045
102
+ typer.Option(help="Stop after this many seconds (default: run until Ctrl+C)."),
103
+ ] = None,
104
+ log_level: Annotated[
105
+ str, typer.Option("--log-level", "-l", help="debug, info, warning or error.")
106
+ ] = "info",
107
+ ) -> None:
108
+ """Start every device in FILE and serve until interrupted."""
109
+ level = getattr(logging, log_level.upper(), None)
110
+ if not isinstance(level, int):
111
+ raise typer.BadParameter(f"unknown log level {log_level!r}", param_hint="--log-level")
112
+ logging.basicConfig(level=level, format="%(asctime)s %(levelname)-7s %(name)s: %(message)s")
113
+
114
+ config = _load(file)
115
+ try:
116
+ asyncio.run(_serve(Simulator(config), duration))
117
+ except KeyboardInterrupt:
118
+ pass
119
+ except OSError as exc:
120
+ typer.echo(f"ERROR cannot start devices: {exc}", err=True)
121
+ raise typer.Exit(code=1) from None
122
+ typer.echo("stopped")
123
+
124
+
125
+ async def _serve(sim: Simulator, duration: float | None) -> None:
126
+ async with sim:
127
+ for device in sim:
128
+ generated = len(device.generated_points)
129
+ typer.echo(
130
+ f"{device.name:<20} {device.protocol:<11} {device.host}:{device.port}"
131
+ f" ({len(device.store.names())} points, {generated} generated)"
132
+ )
133
+ typer.echo("serving - press Ctrl+C to stop" if duration is None else f"serving {duration}s")
134
+ if duration is None:
135
+ await asyncio.Event().wait()
136
+ else:
137
+ await asyncio.sleep(duration)