trnrun 0.4.0__py3-none-win_amd64.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.
trnrun/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from trnrun.config import SimulationConfig
2
+ from trnrun.manager import SimulationManager
3
+ from trnrun.simulation import Simulation
4
+
5
+ __version__ = "0.4.0"
6
+
7
+ __all__ = ["SimulationConfig", "SimulationManager", "Simulation"]
trnrun/bin/trnrun.exe ADDED
Binary file
trnrun/config.py ADDED
@@ -0,0 +1,161 @@
1
+ """Configuration for launching a TRNRun process."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from pathlib import Path
8
+
9
+ # -----------------------------------------------------------------
10
+ # Constants
11
+ # -----------------------------------------------------------------
12
+ # `trnrun.exe` is bundled inside this package under `bin/`.
13
+ _PACKAGE_DIR = Path(__file__).resolve().parent
14
+ BUNDLED_TRNRUN_PATH = _PACKAGE_DIR / "bin" / "trnrun.exe"
15
+
16
+ DEFAULT_TRNEXE_PATH = Path(r"C:\TRNSYS18\Exe\TrnEXE64.exe")
17
+
18
+
19
+ # -----------------------------------------------------------------
20
+ # Configuration
21
+ # -----------------------------------------------------------------
22
+ @dataclass
23
+ class SimulationConfig:
24
+ r"""Configuration used to launch TRNRun.
25
+
26
+ Each field maps to a `trnrun.exe` command-line flag (noted below as
27
+ `--flag`). Flags are passed as `--name:value`. These defaults mirror
28
+ trnrun's own CLI defaults: the `detect`/`watch`/`stall` timeouts are
29
+ `0` (unlimited/disabled), progress tracking (`watch_tmp`) is off, and
30
+ `clean_on_success`, `write_log`, and both kill-on-* behaviors are
31
+ disabled.
32
+
33
+ Attributes
34
+ ----------
35
+ trnrun_path : Path, default `BUNDLED_TRNRUN_PATH`
36
+ Path to the `trnrun.exe` executable to invoke. Defaults to the copy
37
+ bundled with this package.
38
+ trnexe_path : Path, default `DEFAULT_TRNEXE_PATH`
39
+ Path to the TRNSYS executable (`TrnEXE64.exe` or `TrnEXE.exe`),
40
+ passed as `--trnexePath`. trnrun's own fallback is
41
+ `C:\\TRNSYS18\\Exe\\TrnEXE64.exe`; set this if TRNSYS is installed
42
+ elsewhere.
43
+ gui_visibility : str, default `"hidden"`
44
+ TRNSYS window behavior, passed as `--guiVisibility`. One of:
45
+
46
+ - `keep`/`keepOpen` - visible, stays open after the run.
47
+ - `auto`/`autoClose` - visible, closes after the run.
48
+ - `min`/`minimized` - minimized, stays open after the run.
49
+ - `minAuto`/`minimizedAuto` - minimized, closes after the run.
50
+ - `hidden` - no window, closes after the run.
51
+
52
+ wait_for_gui : bool, default `True`
53
+ Wait for a TRNSYS GUI as part of launch detection (`--waitForGui`).
54
+ Launch detection determines when startup has completed so the global
55
+ mutex can be released for the next simulation.
56
+ wait_for_lst : bool, default `True`
57
+ Wait for a specific string to appear in the `*.lst` file during
58
+ launch detection (`--waitForLst`).
59
+ wait_for_tmp : bool, default `False`
60
+ Wait for the `*.tmp` file to appear during launch detection
61
+ (`--waitForTmp`). Requires a Progress Tracker (Type3830) in the deck.
62
+ detect_timeout_ms : int, default `0`
63
+ Timeout in milliseconds for the launch-detection stages
64
+ (`--detectTimeout`). `0` means unlimited. Combined with
65
+ `kill_on_timeout`, exceeding this yields a `TIMEOUT` status.
66
+ extra_delay_ms : int, default `0`
67
+ Additional delay in milliseconds applied after detection passes
68
+ (`--extraDelay`).
69
+ poll_ms : int, default `100`
70
+ Polling interval in milliseconds for the output files and the process
71
+ (`--pollMs`).
72
+ watch_log : bool, default `True`
73
+ Stream `*.log` entries as `LOG` events (`--watchLog`).
74
+ watch_tmp : bool, default `False`
75
+ Stream `*.tmp` updates as `CONFIG`/`PROGRESS` events
76
+ (`--watchTmp`). Requires Type3830. This also gates progress-derived
77
+ outcomes: without it, `CANCELLED` and `STALLED` cannot be detected
78
+ and an early exit is reported as `DONE` instead.
79
+ watch_timeout_ms : int, default `0`
80
+ Maximum runtime-monitoring duration in milliseconds
81
+ (`--watchTimeout`). `0` means unlimited. Exceeding it corresponds
82
+ to a `TIMEOUT` status / exit code 124.
83
+ stall_timeout_ms : int, default `0`
84
+ Maximum wall-clock time in milliseconds with no simulation-time
85
+ progress before the run is considered stalled (`--stallTimeout`).
86
+ `0` disables the check. Requires `watch_tmp=True`. A stall yields a
87
+ `STALLED` status / exit code 125.
88
+ clean_on_success : bool, default `False`
89
+ On a successful run, delete the `*.tmp`, `*.log`, `*.lst`, and
90
+ `*.PTI` artifacts (`--clean`).
91
+ kill_on_timeout : bool, default `False`
92
+ Kill the TRNSYS process on a detection or watch timeout
93
+ (`--killOnTimeout`). If `False`, the runner waits for it to exit.
94
+ kill_on_stall : bool, default `False`
95
+ Kill the TRNSYS process when a stall is detected (`--killOnStall`).
96
+ If `False`, the runner waits for it to exit.
97
+ severity : str, default `"Notice"`
98
+ Minimum log severity to emit (`--severity`), one of `"Notice"`,
99
+ `"Warning"`, or `"Fatal"`.
100
+ write_log : bool, default `False`
101
+ Also append every emitted event to `<deckFile>.jsonl`
102
+ (`--writeLog`).
103
+ """
104
+
105
+ trnrun_path: Path = BUNDLED_TRNRUN_PATH
106
+ trnexe_path: Path = DEFAULT_TRNEXE_PATH
107
+ gui_visibility: str = "hidden"
108
+ wait_for_gui: bool = True
109
+ wait_for_lst: bool = True
110
+ wait_for_tmp: bool = False
111
+ detect_timeout_ms: int = 0
112
+ extra_delay_ms: int = 0
113
+ poll_ms: int = 100
114
+ watch_log: bool = True
115
+ watch_tmp: bool = False
116
+ watch_timeout_ms: int = 0
117
+ stall_timeout_ms: int = 0
118
+ clean_on_success: bool = False
119
+ kill_on_timeout: bool = False
120
+ kill_on_stall: bool = False
121
+ severity: str = "Notice"
122
+ write_log: bool = False
123
+
124
+ def validate(self) -> None:
125
+ """Check that both executables exist.
126
+
127
+ Raises
128
+ ------
129
+ FileNotFoundError
130
+ If `trnrun_path` or `trnexe_path` is not a file.
131
+ """
132
+ if not self.trnrun_path.is_file():
133
+ raise FileNotFoundError(f"TRNRun executable not found: {self.trnrun_path}")
134
+ if not self.trnexe_path.is_file():
135
+ raise FileNotFoundError(f"TrnEXE executable not found: {self.trnexe_path}")
136
+
137
+ def to_cli_args(self) -> list[str]:
138
+ """Convert configuration into `trnrun.exe` command-line arguments."""
139
+
140
+ def boolean(value: bool) -> str:
141
+ return "true" if value else "false"
142
+
143
+ return [
144
+ f"--trnexePath:{self.trnexe_path}",
145
+ f"--guiVisibility:{self.gui_visibility}",
146
+ f"--waitForGui:{boolean(self.wait_for_gui)}",
147
+ f"--waitForLst:{boolean(self.wait_for_lst)}",
148
+ f"--waitForTmp:{boolean(self.wait_for_tmp)}",
149
+ f"--detectTimeout:{self.detect_timeout_ms}",
150
+ f"--extraDelay:{self.extra_delay_ms}",
151
+ f"--pollMs:{self.poll_ms}",
152
+ f"--watchLog:{boolean(self.watch_log)}",
153
+ f"--watchTmp:{boolean(self.watch_tmp)}",
154
+ f"--watchTimeout:{self.watch_timeout_ms}",
155
+ f"--stallTimeout:{self.stall_timeout_ms}",
156
+ f"--clean:{boolean(self.clean_on_success)}",
157
+ f"--killOnTimeout:{boolean(self.kill_on_timeout)}",
158
+ f"--killOnStall:{boolean(self.kill_on_stall)}",
159
+ f"--severity:{self.severity}",
160
+ f"--writeLog:{boolean(self.write_log)}",
161
+ ]
trnrun/display.py ADDED
@@ -0,0 +1,170 @@
1
+ """Terminal display for TRNRun-manager simulations using Rich.
2
+
3
+ Provides live terminal output for running simulations and prints final
4
+ results when they complete. The display does not own simulation state; it
5
+ only renders the state provided by ``SimulationManager``.
6
+ """
7
+
8
+ # pyright: reportUnusedCallResult=false
9
+
10
+ from __future__ import annotations
11
+
12
+ from threading import Lock
13
+
14
+ from rich.console import Console, Group
15
+ from rich.live import Live
16
+ from rich.text import Text
17
+
18
+ from trnrun.simulation import Simulation, SimulationSnapshot
19
+ from trnrun.utils import format_hhmmss, truncate_left
20
+
21
+ # -----------------------------------------------------------------
22
+ # Constants
23
+ # -----------------------------------------------------------------
24
+ COLOR_MAP: dict[str, str | None] = {
25
+ "PENDING": None,
26
+ "RUNNING": None,
27
+ "DONE": "green",
28
+ "ERROR": "red",
29
+ "TIMEOUT": "red",
30
+ "STALLED": "red",
31
+ "CANCELLED": "yellow",
32
+ }
33
+
34
+ PATH_WIDTH = 32
35
+ PROGRESS_BAR_WIDTH = 20
36
+ MS_PER_SECOND = 1000
37
+
38
+
39
+ # -----------------------------------------------------------------
40
+ # Null Display
41
+ # -----------------------------------------------------------------
42
+ class NullDisplay:
43
+ """Display that renders nothing; for headless runs and tests."""
44
+
45
+ def simulation_started(self, _simulation: Simulation) -> None:
46
+ """Ignore simulation start events."""
47
+
48
+ def simulation_finished(self, _simulation: Simulation) -> None:
49
+ """Ignore simulation finish events."""
50
+
51
+
52
+ # -----------------------------------------------------------------
53
+ # Display
54
+ # -----------------------------------------------------------------
55
+ class Display:
56
+ """Live terminal view of currently running simulations.
57
+
58
+ Parameters
59
+ ----------
60
+ refresh_interval : float, optional
61
+ Time in seconds between live region refreshes. Must be positive.
62
+ """
63
+
64
+ def __init__(self, refresh_interval: float = 1.0) -> None:
65
+ if refresh_interval <= 0:
66
+ raise ValueError("refresh_interval must be positive")
67
+
68
+ self.console: Console = Console()
69
+
70
+ self._lock: Lock = Lock()
71
+ self._live_lock: Lock = Lock()
72
+
73
+ self._active: dict[int, Simulation] = {}
74
+ self._refresh_per_second: float = 1 / refresh_interval
75
+ self._live: Live | None = None
76
+
77
+ # -----------------------------------------------------------------
78
+ # Event Handlers
79
+ # -----------------------------------------------------------------
80
+ def simulation_started(self, simulation: Simulation) -> None:
81
+ """Add a simulation to the live display."""
82
+ with self._live_lock:
83
+ with self._lock:
84
+ self._active[simulation.id] = simulation
85
+
86
+ if self._live is None:
87
+ live = self._make_live()
88
+ live.start()
89
+ self._live = live
90
+
91
+ def simulation_finished(self, simulation: Simulation) -> None:
92
+ """Remove a simulation and print its final state."""
93
+ with self._live_lock:
94
+ with self._lock:
95
+ self._active.pop(simulation.id, None)
96
+ empty = not self._active
97
+
98
+ self.console.print(self._render_line(simulation.snapshot()))
99
+
100
+ if empty and self._live is not None:
101
+ live, self._live = self._live, None
102
+ live.stop()
103
+
104
+ # -----------------------------------------------------------------
105
+ # Rendering
106
+ # -----------------------------------------------------------------
107
+ def _make_live(self) -> Live:
108
+ """Build a fresh transient live display."""
109
+ return Live(
110
+ get_renderable=self._render_all,
111
+ console=self.console,
112
+ refresh_per_second=self._refresh_per_second,
113
+ transient=True,
114
+ )
115
+
116
+ def _progress_bar(self, percent: float, width: int = PROGRESS_BAR_WIDTH) -> str:
117
+ """Return a fixed-width ASCII completion bar."""
118
+ filled = min(max(int(width * percent), 0), width)
119
+ return "[" + "#" * filled + "-" * (width - filled) + "]"
120
+
121
+ def _render_line(self, sim: SimulationSnapshot) -> Text:
122
+ """Render one simulation snapshot."""
123
+ path = truncate_left(str(sim.deck_path), PATH_WIDTH)
124
+
125
+ status = sim.status.status if sim.status else "PENDING"
126
+ status_style = COLOR_MAP.get(status.upper())
127
+
128
+ logs = f"N:{sim.notices} W:{sim.warnings} F:{sim.fatals}"
129
+
130
+ progress = sim.progress
131
+
132
+ elapsed = format_hhmmss(progress.elapsed / MS_PER_SECOND if progress else None)
133
+ eta = format_hhmmss(progress.eta / MS_PER_SECOND if progress else None)
134
+
135
+ sim_time = progress.time if progress else None
136
+ percent = progress.percent if progress else None
137
+
138
+ config = sim.config_event
139
+ sim_stop = config.stop if config else None
140
+
141
+ if percent is None:
142
+ bar = "[" + "-" * PROGRESS_BAR_WIDTH + "]"
143
+ sim_percent = ""
144
+ else:
145
+ bar = self._progress_bar(percent)
146
+ sim_percent = f"({percent * 100:.0f}%)"
147
+
148
+ sim_progress = "- / -" if sim_time is None or sim_stop is None else f"{sim_time:6,.0f} / {sim_stop:6,.0f}"
149
+
150
+ text = Text()
151
+ text.append(f"[{sim.id}] ")
152
+ text.append(f"{path:<{PATH_WIDTH}} │ ")
153
+ text.append("Status: ")
154
+ text.append(f"{status:<10}", style=status_style)
155
+ text.append(" │ ")
156
+ text.append(f"Logs: {logs:<12} │ ")
157
+ text.append(f"Elapsed: {elapsed:<8} │ ETA: {eta:<8} │ ")
158
+ text.append(f"{bar} {sim_progress} {sim_percent:6}")
159
+
160
+ if sim.error is not None:
161
+ text.append(f" │ error: {sim.error}", style="red")
162
+
163
+ return text
164
+
165
+ def _render_all(self) -> Group:
166
+ """Render all active simulations."""
167
+ with self._lock:
168
+ active = list(self._active.values())
169
+
170
+ return Group(*(self._render_line(sim.snapshot()) for sim in active))
trnrun/events.py ADDED
@@ -0,0 +1,307 @@
1
+ """Typed events emitted by TRNRun on stdout, and their JSONL parsers.
2
+
3
+ TRNRun writes one JSON object per line. ``parse_event`` turns a single
4
+ line into a typed event; ``stream_events`` does the same for an entire
5
+ stream of lines.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections.abc import Callable, Iterable, Iterator
12
+ from dataclasses import dataclass
13
+ from typing import Final, cast
14
+
15
+
16
+ # -----------------------------------------------------------------
17
+ # Exceptions
18
+ # -----------------------------------------------------------------
19
+ class EventParseError(ValueError):
20
+ """Raised when a JSON line cannot be parsed into a TRNRun event."""
21
+
22
+
23
+ # -----------------------------------------------------------------
24
+ # Events
25
+ # -----------------------------------------------------------------
26
+ @dataclass(frozen=True)
27
+ class StatusEvent:
28
+ """A STATUS event reporting the run's current state.
29
+
30
+ Attributes
31
+ ----------
32
+ status : str
33
+ State reported by TRNRun.
34
+ timestamp : str
35
+ Timestamp attached to the event by TRNRun.
36
+ """
37
+
38
+ status: str
39
+ timestamp: str
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class ProgressEvent:
44
+ """A PROGRESS event reporting run completion and timing.
45
+
46
+ Attributes
47
+ ----------
48
+ time : float
49
+ Current simulation time.
50
+ percent : float
51
+ Completion of the run as a fraction from 0 to 1.
52
+ elapsed : float
53
+ Wall-clock milliseconds elapsed since the run started.
54
+ eta : float
55
+ Estimated wall-clock milliseconds remaining.
56
+ timestamp : str
57
+ Timestamp attached to the event by TRNRun.
58
+ """
59
+
60
+ time: float
61
+ percent: float
62
+ elapsed: float
63
+ eta: float
64
+ timestamp: str
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class ConfigEvent:
69
+ """A CONFIG event reporting the run's sweep parameters.
70
+
71
+ Attributes
72
+ ----------
73
+ start : float
74
+ Simulation start time.
75
+ stop : float
76
+ Simulation stop time.
77
+ step : float
78
+ Simulation time step.
79
+ timestamp : str
80
+ Timestamp attached to the event by TRNRun.
81
+ """
82
+
83
+ start: float
84
+ stop: float
85
+ step: float
86
+ timestamp: str
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class LogEvent:
91
+ """A LOG event carrying a severity-tagged message.
92
+
93
+ Only ``severity`` and ``timestamp`` are guaranteed; the remaining
94
+ fields depend on what TRNRun attaches to the message.
95
+
96
+ Attributes
97
+ ----------
98
+ severity : str
99
+ Severity tag, e.g. ``"notice"``, ``"warning"`` or ``"fatal"``.
100
+ timestamp : str
101
+ Timestamp attached to the event by TRNRun.
102
+ time : float or None
103
+ Simulation time at which the message was produced.
104
+ unit_id : int or None
105
+ Unit that emitted the message.
106
+ type_id : int or None
107
+ Type of the unit that emitted the message.
108
+ message_code : int or None
109
+ Numeric code identifying the message.
110
+ message : str or None
111
+ Human-readable message text.
112
+ information : str or None
113
+ Additional detail attached to the message.
114
+ """
115
+
116
+ severity: str
117
+ timestamp: str
118
+ time: float | None = None
119
+ unit_id: int | None = None
120
+ type_id: int | None = None
121
+ message_code: int | None = None
122
+ message: str | None = None
123
+ information: str | None = None
124
+
125
+
126
+ type TrnRunEvent = StatusEvent | ProgressEvent | ConfigEvent | LogEvent
127
+
128
+
129
+ # -----------------------------------------------------------------
130
+ # Validation Helpers
131
+ # -----------------------------------------------------------------
132
+ def _required(data: dict[str, object], key: str) -> object:
133
+ """Return a required field, raising ``EventParseError`` if missing."""
134
+ try:
135
+ return data[key]
136
+ except KeyError as e:
137
+ raise EventParseError(f"missing required field '{key}'") from e
138
+
139
+
140
+ def _require_str(data: dict[str, object], key: str) -> str:
141
+ """Return a required string field."""
142
+ value = _required(data, key)
143
+
144
+ if not isinstance(value, str):
145
+ raise EventParseError(f"field '{key}' must be a string")
146
+
147
+ return value
148
+
149
+
150
+ def _require_float(data: dict[str, object], key: str) -> float:
151
+ """Return a required numeric field as a float, rejecting booleans."""
152
+ value = _required(data, key)
153
+
154
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
155
+ raise EventParseError(f"field '{key}' must be a number")
156
+
157
+ return float(value)
158
+
159
+
160
+ def _require_int(data: dict[str, object], key: str) -> int:
161
+ """Return a required integer field, rejecting booleans."""
162
+ value = _required(data, key)
163
+
164
+ if isinstance(value, bool) or not isinstance(value, int):
165
+ raise EventParseError(f"field '{key}' must be an integer")
166
+
167
+ return value
168
+
169
+
170
+ def _optional_str(data: dict[str, object], key: str) -> str | None:
171
+ """Return an optional string field, treating JSON null as absent."""
172
+ return None if data.get(key) is None else _require_str(data, key)
173
+
174
+
175
+ def _optional_float(data: dict[str, object], key: str) -> float | None:
176
+ """Return an optional numeric field, treating JSON null as absent."""
177
+ return None if data.get(key) is None else _require_float(data, key)
178
+
179
+
180
+ def _optional_int(data: dict[str, object], key: str) -> int | None:
181
+ """Return an optional integer field, treating JSON null as absent."""
182
+ return None if data.get(key) is None else _require_int(data, key)
183
+
184
+
185
+ # -----------------------------------------------------------------
186
+ # Event Parsers
187
+ # -----------------------------------------------------------------
188
+ def _parse_status(data: dict[str, object]) -> StatusEvent:
189
+ """Parse a STATUS event."""
190
+ return StatusEvent(
191
+ status=_require_str(data, "status"),
192
+ timestamp=_require_str(data, "timestamp"),
193
+ )
194
+
195
+
196
+ def _parse_progress(data: dict[str, object]) -> ProgressEvent:
197
+ """Parse a PROGRESS event."""
198
+ return ProgressEvent(
199
+ time=_require_float(data, "time"),
200
+ percent=_require_float(data, "percent"),
201
+ elapsed=_require_float(data, "elapsed"),
202
+ eta=_require_float(data, "eta"),
203
+ timestamp=_require_str(data, "timestamp"),
204
+ )
205
+
206
+
207
+ def _parse_config(data: dict[str, object]) -> ConfigEvent:
208
+ """Parse a CONFIG event."""
209
+ return ConfigEvent(
210
+ start=_require_float(data, "start"),
211
+ stop=_require_float(data, "stop"),
212
+ step=_require_float(data, "step"),
213
+ timestamp=_require_str(data, "timestamp"),
214
+ )
215
+
216
+
217
+ def _parse_log(data: dict[str, object]) -> LogEvent:
218
+ """Parse a LOG event."""
219
+ return LogEvent(
220
+ severity=_require_str(data, "severity"),
221
+ timestamp=_require_str(data, "timestamp"),
222
+ time=_optional_float(data, "time"),
223
+ unit_id=_optional_int(data, "unitId"),
224
+ type_id=_optional_int(data, "typeId"),
225
+ message_code=_optional_int(data, "messageCode"),
226
+ message=_optional_str(data, "message"),
227
+ information=_optional_str(data, "information"),
228
+ )
229
+
230
+
231
+ # Dispatch table mapping an event's "kind" to its parser.
232
+ _PARSERS: Final[dict[str, Callable[[dict[str, object]], TrnRunEvent]]] = {
233
+ "STATUS": _parse_status,
234
+ "PROGRESS": _parse_progress,
235
+ "CONFIG": _parse_config,
236
+ "LOG": _parse_log,
237
+ }
238
+
239
+
240
+ # -----------------------------------------------------------------
241
+ # Parsing
242
+ # -----------------------------------------------------------------
243
+ def parse_event(line: str) -> TrnRunEvent:
244
+ """Parse one JSON-encoded TRNRun event.
245
+
246
+ Parameters
247
+ ----------
248
+ line : str
249
+ A single line of TRNRun stdout containing one JSON object.
250
+
251
+ Returns
252
+ -------
253
+ TrnRunEvent
254
+ The typed event corresponding to the object's ``kind``.
255
+
256
+ """
257
+ try:
258
+ value = cast("object", json.loads(line))
259
+ except json.JSONDecodeError as e:
260
+ raise EventParseError(f"invalid JSON: {e}") from e
261
+
262
+ if not isinstance(value, dict):
263
+ raise EventParseError("event must be a JSON object")
264
+
265
+ data = cast("dict[str, object]", value)
266
+
267
+ kind = _require_str(data, "kind").upper()
268
+
269
+ try:
270
+ parser = _PARSERS[kind]
271
+ except KeyError as e:
272
+ raise EventParseError(f"unknown event kind '{kind}'") from e
273
+
274
+ return parser(data)
275
+
276
+
277
+ def stream_events(
278
+ lines: Iterable[str],
279
+ *,
280
+ skip_invalid: bool = False,
281
+ ) -> Iterator[TrnRunEvent]:
282
+ """Yield parsed events from a JSONL stream, skipping blank lines.
283
+
284
+ Parameters
285
+ ----------
286
+ lines : Iterable[str]
287
+ Lines of TRNRun stdout, one JSON object per line.
288
+ skip_invalid : bool, optional
289
+ If true, silently drop lines that fail to parse instead of
290
+ raising. Defaults to False.
291
+
292
+ Yields
293
+ ------
294
+ TrnRunEvent
295
+ One typed event per successfully parsed line.
296
+ """
297
+ for raw_line in lines:
298
+ line = raw_line.strip()
299
+
300
+ if not line:
301
+ continue
302
+
303
+ try:
304
+ yield parse_event(line)
305
+ except EventParseError:
306
+ if not skip_invalid:
307
+ raise