syndesi 0.5.0__py3-none-any.whl → 0.5.1__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.
@@ -0,0 +1,711 @@
1
+ # File : syndesi_trace.py
2
+ # Author : Sébastien Deriaz
3
+ # License : GPL
4
+ """
5
+ CLI viewer for Syndesi trace events (UDP).
6
+
7
+ This tool allows the user to see what's going inside syndesi while it's running in another process
8
+
9
+ Usage :
10
+
11
+ syndesi-trace [--mode MODE]
12
+
13
+ Modes
14
+ -----
15
+ - interactive (default): tabs per adapter, switch with ←/→ (or h/l), quit with q.
16
+ - flat: append-only timeline (stdout or --output file).
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from abc import abstractmethod
21
+ from dataclasses import dataclass
22
+ from enum import StrEnum
23
+ from types import TracebackType
24
+ import argparse
25
+ import json
26
+ import os
27
+ import selectors
28
+ import socket
29
+ import struct
30
+ import sys
31
+ from collections import OrderedDict, deque
32
+ from collections.abc import Generator
33
+ from typing import Any, TYPE_CHECKING
34
+ from fnmatch import fnmatchcase
35
+ import termios
36
+ import tty
37
+
38
+ from syndesi.adapters.tracehub import (
39
+ DEFAULT_MULTICAST_GROUP,
40
+ DEFAULT_MULTICAST_PORT,
41
+ CloseEvent,
42
+ FragmentEvent,
43
+ OpenEvent,
44
+ ReadEvent,
45
+ TraceEvent,
46
+ WriteEvent,
47
+ json_to_trace_event,
48
+ )
49
+
50
+ if TYPE_CHECKING:
51
+ from rich.align import Align
52
+ from rich.console import Console
53
+ from rich.live import Live
54
+ from rich.panel import Panel
55
+ from rich.text import Text
56
+ rich_available = True
57
+ else:
58
+ try:
59
+ from rich.align import Align
60
+ from rich.console import Console
61
+ from rich.live import Live
62
+ from rich.panel import Panel
63
+ from rich.text import Text
64
+ rich_available = True
65
+ except ImportError:
66
+ rich_available = False
67
+
68
+ class TraceMode(StrEnum):
69
+ """Trace mode"""
70
+ INTERACTIVE = 'interactive'
71
+ FLAT = 'flat'
72
+
73
+ class TimeMode(StrEnum):
74
+ """Time display mode"""
75
+ ABSOLUTE = 'abs'
76
+ RELATIVE = 'rel'
77
+
78
+ # -----------------------------
79
+ # Terminal input (interactive)
80
+ # -----------------------------
81
+
82
+ #pylint: disable=too-many-instance-attributes
83
+ class Trace:
84
+ """Trace viewer base class"""
85
+ ADAPTER_STYLES = ["cyan", "magenta", "green", "yellow", "blue", "bright_cyan",
86
+ "bright_magenta", "bright_green"]
87
+ def __init__(
88
+ self,
89
+ *,
90
+ group : str,
91
+ port : int,
92
+ descriptor_filter : str,
93
+ time_mode : str,
94
+ no_fragments : bool
95
+ ) -> None:
96
+ self.group = group
97
+ self.port = port
98
+
99
+ self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
100
+ self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
101
+ self._sock.bind(("0.0.0.0", self.port))
102
+ mreq = struct.pack("4s4s", socket.inet_aton(self.group), socket.inet_aton("127.0.0.1"))
103
+ self._sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
104
+ self._sock.setblocking(False)
105
+
106
+ self._selector = selectors.DefaultSelector()
107
+ self._selector.register(self._sock, selectors.EVENT_READ, data="udp")
108
+
109
+ self._descriptor_filter = descriptor_filter.lower()
110
+
111
+ self._console = Console()
112
+
113
+ self._no_fragments = no_fragments
114
+
115
+ self._adapters: OrderedDict[str, AdapterProperties] = OrderedDict()
116
+
117
+ # Time
118
+ self._time_mode = TimeMode(time_mode)
119
+ self._first_event_timestamp : float | None = None
120
+
121
+ @abstractmethod
122
+ def run(self) -> None:
123
+ """Base run method"""
124
+
125
+ def close(self) -> None:
126
+ """Close the trace viewer"""
127
+ self._selector.unregister(self._sock)
128
+ try:
129
+ self._sock.close()
130
+ except OSError:
131
+ pass
132
+
133
+ def _allow_descriptor(self, name: str) -> bool:
134
+ return fnmatchcase(name.lower(), self._descriptor_filter)
135
+
136
+ def get_event(self, timeout : float | None) -> Generator[TraceEvent | None, Any, Any]:
137
+ """Return an event or None if something else triggered the selector"""
138
+ events = self._selector.select(timeout=timeout)
139
+ for key, _ in events:
140
+ if key.data == "udp":
141
+ while True:
142
+ try:
143
+ data, _addr = self._sock.recvfrom(65535)
144
+ except BlockingIOError:
145
+ break
146
+
147
+ json_data = json.loads(data.decode("utf-8", "replace"))
148
+ # Parse data
149
+ yield json_to_trace_event(json_data)
150
+ else:
151
+ yield None
152
+
153
+ def _time_prefix(self, event : TraceEvent) -> str:
154
+ if self._first_event_timestamp is None:
155
+ self._first_event_timestamp = event.timestamp
156
+
157
+ if self._time_mode == TimeMode.RELATIVE:
158
+ relative_time = event.timestamp - self._first_event_timestamp
159
+ return f'{relative_time:+8.3f}s'
160
+ if self._time_mode == TimeMode.ABSOLUTE:
161
+ return f'{event.timestamp:.3f}s'
162
+ raise ValueError(f"Invalid time mode : {self._time_mode}")
163
+
164
+
165
+ def _format_entry(self, event : TraceEvent, display_descriptor : bool = False) -> Text | None:
166
+ fragments = [Text(self._time_prefix(event), style="dim"), Text(" ")]
167
+
168
+ adapter = self._adapters[event.descriptor]
169
+
170
+ if display_descriptor:
171
+ fragments += [
172
+ Text(event.descriptor, style="dim"),
173
+ Text(" ")
174
+ ]
175
+
176
+ #last_write : float | None = None#self._adapter_last_write.get(event.descriptor, None)
177
+
178
+ if isinstance(event, OpenEvent):
179
+ fragments.append(
180
+ Text("open ●", style="bold green")
181
+ )
182
+ elif isinstance(event, CloseEvent):
183
+ fragments.append(
184
+ Text("close ●", style="bold red")
185
+ )
186
+
187
+ elif isinstance(event, WriteEvent):
188
+ adapter.last_write_timestamp = event.timestamp
189
+ #self._adapter_last_write[event.descriptor] = event.timestamp
190
+ fragments += [
191
+ Text("write →", style="bold dim"),
192
+ Text(f"{event.length:4d}B", style="dim"),
193
+ Text(f" {event.data}")
194
+ ]
195
+ elif isinstance(event, FragmentEvent):
196
+ if self._no_fragments:
197
+ return None
198
+ fragments += [
199
+ Text(" ↓", style="dim"),
200
+ Text(f"{event.length:4d}B ", style="dim"),
201
+ Text(event.data, style="dim"),
202
+ Text(" (frag)", style="dim")
203
+ ]
204
+ if adapter.last_write_timestamp is not None:
205
+ write_delta = event.timestamp - adapter.last_write_timestamp
206
+ fragments.append(
207
+ Text(f" {write_delta:+.3f}s", style="dim")
208
+ )
209
+ elif isinstance(event, ReadEvent):
210
+ fragments += [
211
+ Text("read ←", style="bold dim"),
212
+ Text(f"{event.length:4d}B ", style="dim"),
213
+ Text(f"{event.data} "),
214
+ Text(f'({event.stop_condition_indicator})', style="dim")
215
+ ]
216
+ if adapter.last_write_timestamp is not None:
217
+ write_delta = event.timestamp - adapter.last_write_timestamp
218
+ fragments.append(
219
+ Text(f" {write_delta:+.3f}s", style="dim")
220
+ )
221
+ else:
222
+ fragments += [
223
+ Text("ERROR", style="bold red")
224
+ ]
225
+
226
+ return Text.assemble(
227
+ *fragments
228
+ )
229
+
230
+ def _next_style(self) -> str:
231
+ color = self.ADAPTER_STYLES[len(self._adapters) % len(self.ADAPTER_STYLES)]
232
+ return color
233
+
234
+ def ingest(self, event : TraceEvent, max_events : int = 0) -> bool:
235
+ """Ingest event"""
236
+ if not self._allow_descriptor(event.descriptor):
237
+ return False
238
+
239
+ if event.descriptor not in self._adapters:
240
+ self._adapters[event.descriptor] = AdapterProperties(
241
+ blocks=deque([], max_events) if max_events > 0 else None,
242
+ style=self._next_style()
243
+ )
244
+ return True
245
+
246
+ class FlatTrace(Trace):
247
+ """Flat trace (stdout or file)"""
248
+ def __init__(
249
+ self,
250
+ *,
251
+ group: str,
252
+ port: int,
253
+ time_mode : str,
254
+ descriptor_filter : str,
255
+ no_fragments : bool,
256
+ output: str | None
257
+ ) -> None:
258
+ super().__init__(
259
+ group=group,
260
+ port=port,
261
+ descriptor_filter=descriptor_filter,
262
+ time_mode=time_mode,
263
+ no_fragments=no_fragments
264
+ )
265
+
266
+ # pylint: disable=consider-using-with
267
+ self._output_fh = open(output, "a", encoding="utf-8") if output else None
268
+
269
+ class CSVColumn(StrEnum):
270
+ """Name of CSV columns"""
271
+ DESCRIPTOR = "descriptor"
272
+ TIME = "time"
273
+ EVENT = "event"
274
+ SIZE = "size"
275
+ DATA = "data"
276
+ STOP_CONDITION = "stop_condition"
277
+
278
+ def run(self) -> None:
279
+ # No live UI; just print as events arrive.
280
+ while True:
281
+ for event in self.get_event(None):
282
+ if event is not None and self._allow_descriptor(event.descriptor):
283
+ if self._output_fh is not None:
284
+ file_event = self._format_file_entry(event)
285
+ if file_event is not None:
286
+ self._output_fh.write(file_event)
287
+ self.ingest(event)#self.print_event(event)
288
+
289
+ def _format_file_entry(self, event : TraceEvent) -> str | None:
290
+ columns : OrderedDict[FlatTrace.CSVColumn, str] = OrderedDict(
291
+ {k : "" for k in self.CSVColumn}
292
+ )
293
+
294
+ if isinstance(event, FragmentEvent) and self._no_fragments:
295
+ return None
296
+
297
+ columns[self.CSVColumn.DESCRIPTOR] = event.descriptor
298
+ columns[self.CSVColumn.TIME] = f"{event.timestamp:.6f}"
299
+ columns[self.CSVColumn.EVENT] = event.t
300
+
301
+ if isinstance(event, (WriteEvent, ReadEvent, FragmentEvent)):
302
+ columns[self.CSVColumn.DATA] = event.data
303
+ columns[self.CSVColumn.SIZE] = str(event.length)
304
+
305
+ if isinstance(event, ReadEvent):
306
+ columns[self.CSVColumn.STOP_CONDITION] = event.stop_condition_indicator
307
+
308
+ return ','.join(columns.values()) + '\n'
309
+
310
+ def close(self) -> None:
311
+ if self._output_fh:
312
+ try:
313
+ self._output_fh.flush()
314
+ self._output_fh.close()
315
+ except OSError:
316
+ pass
317
+ self._output_fh = None
318
+ super().close()
319
+
320
+ def _print_header(self) -> None:
321
+ hdr = f"# syndesi-trace flat mode | host={self.group} port={self.port}"
322
+ print(hdr)
323
+
324
+ def print_event(self, event : TraceEvent) -> None:
325
+ """Print event to the console"""
326
+ output = self._format_entry(event, display_descriptor=True)
327
+ if output is not None:
328
+ self._console.print(output)
329
+
330
+ class InteractiveTrace(Trace):
331
+ """Interactive trace with tabs for each adapter"""
332
+
333
+
334
+ def __init__(
335
+ self,
336
+ *,
337
+ group: str,
338
+ port: int,
339
+ time_mode : str,
340
+ descriptor_filter: str,
341
+ no_fragments : bool,
342
+ max_events: int
343
+ ) -> None:
344
+ super().__init__(
345
+ group=group,
346
+ port=port,
347
+ descriptor_filter=descriptor_filter,
348
+ time_mode=time_mode,
349
+ no_fragments=no_fragments
350
+ )
351
+
352
+ if Console is None:
353
+ print("This viewer requires 'rich'. Install it with: pip install rich", file=sys.stderr)
354
+ raise SystemExit(2)
355
+
356
+ self._use_stdin = os.name == "posix"
357
+ if self._use_stdin:
358
+ self._selector.register(sys.stdin, selectors.EVENT_READ, data="stdin")
359
+
360
+ self._selected_idx: int = 0
361
+
362
+ self.max_events = max_events
363
+
364
+ self._scroll_offset : int = 0
365
+ self._auto_follow : bool = True
366
+
367
+
368
+ def _handle_key(self, token: str) -> bool:
369
+ # returns False to quit
370
+ token = token.strip()
371
+ if token in ("q", "Q"):
372
+ return False
373
+ if token in ("LEFT", "h"):
374
+
375
+ if self._adapters:
376
+ self._selected_idx = (self._selected_idx - 1) % len(self._adapters)
377
+ if token in ("RIGHT", "l"):
378
+ if self._adapters:
379
+ self._selected_idx = (self._selected_idx + 1) % len(self._adapters)
380
+ if token in ("UP", "k"):
381
+ if self._adapters:
382
+ #st = self.adapters[self.adapter_order[self.selected_idx]]
383
+ self._auto_follow = False
384
+ self._scroll_offset += 1
385
+ if token in ("DOWN", "j"):
386
+ if self._adapters:
387
+ #st = self.adapters[self.adapter_order[self.selected_idx]]
388
+ if self._scroll_offset > 0:
389
+ self._scroll_offset -= 1
390
+ if self._scroll_offset == 0:
391
+ self._auto_follow = True
392
+ return True
393
+
394
+ def run(self) -> None:
395
+ with _TerminalRawMode():
396
+ with Live(self._render_interactive(), refresh_per_second=20, screen=True) as live:
397
+ running = True
398
+ while running:
399
+
400
+ for event in self.get_event(timeout=0.05):
401
+ if event is None:
402
+ for k in _read_keys_nonblocking():
403
+ running = self._handle_key(k)
404
+ if not running:
405
+ break
406
+ else:
407
+ self.ingest(event)
408
+
409
+ if os.name == "nt":
410
+ for k in _read_keys_nonblocking():
411
+ running = self._handle_key(k)
412
+ if not running:
413
+ break
414
+
415
+ live.update(self._render_interactive())
416
+ if not running:
417
+ break
418
+
419
+ def ingest(self, event : TraceEvent, max_events : int = 0) -> bool:
420
+ """Manage an incoming event"""
421
+ if not super().ingest(event, self.max_events):
422
+ return False
423
+
424
+ adapter = self._adapters[event.descriptor]
425
+
426
+ block = self._format_entry(event)
427
+ if block is not None:
428
+ if adapter.blocks is not None:
429
+ adapter.blocks.append(block)
430
+
431
+ return True
432
+
433
+ def _render_tabs(self) -> Text:
434
+ t = Text()
435
+ t.append("Adapters: ", style="dim")
436
+ if self._adapters:
437
+ for i, (name, adapter) in enumerate(self._adapters.items()):
438
+ if i == self._selected_idx:
439
+ style = f"bold reverse {adapter.style}"
440
+ else:
441
+ style = f"{adapter.style}"
442
+ t.append(f" {name} ", style=style)
443
+ else:
444
+ t.append("(no adapters)")
445
+
446
+ t.append(" ", style="dim")
447
+ t.append("←/→ switch ↑/↓ scroll q quit (h/j/k/l also)", style="dim")
448
+ return t
449
+
450
+ def _render_interactive(self) -> Panel:
451
+ if not self._adapters:
452
+ body = Text("Waiting for events…", style="dim")
453
+ return Panel(Align.left(body), title="Syndesi Trace", border_style="dim")
454
+
455
+ # Compose recent blocks into lines
456
+ lines: list[Text] = []
457
+ selected_adapter = list(self._adapters.values())[self._selected_idx]
458
+ if selected_adapter.blocks is not None:
459
+ for block in selected_adapter.blocks:
460
+ lines.append(block)
461
+
462
+ if not lines:
463
+ lines = [Text("No events yet for this adapter.", style="dim")]
464
+
465
+ body = Text("\n").join(self._visible_lines(lines))
466
+ header = self._render_tabs()
467
+
468
+ # Wrap body in a panel; header goes as title-ish via subtitle
469
+ return Panel(
470
+ Align.left(body),
471
+ title=header,
472
+ border_style="bright_black",
473
+ padding=(1, 1),
474
+ )
475
+
476
+ def _visible_lines(self, lines: list[Text]) -> list[Text]:
477
+ height = self._console.size.height if self._console is not None else 24
478
+ # Reserve space for borders, title, and padding.
479
+ visible = max(1, height - 6)
480
+ total = len(lines)
481
+ max_offset = max(0, total - visible)
482
+
483
+ if self._auto_follow:
484
+ self._scroll_offset = 0
485
+ else:
486
+ self._scroll_offset = min(max_offset, self._scroll_offset)
487
+ start = max(0, total - visible - self._scroll_offset)
488
+ return lines[start:start + visible]
489
+
490
+ class _TerminalRawMode:
491
+ """Best-effort cbreak/raw mode (POSIX). Windows falls back to polling msvcrt."""
492
+ def __init__(self) -> None:
493
+ self._enabled = False
494
+ self._old : list[Any] | None = None
495
+
496
+ def __enter__(self) -> _TerminalRawMode:
497
+ if os.name != "posix":
498
+ return self
499
+ try:
500
+ fd = sys.stdin.fileno()
501
+ self._old = termios.tcgetattr(fd)
502
+ tty.setcbreak(fd)
503
+ self._enabled = True
504
+ except OSError:
505
+ self._enabled = False
506
+ return self
507
+
508
+ def __exit__(
509
+ self,
510
+ type_: type[BaseException] | None,
511
+ value: BaseException | None,
512
+ traceback: TracebackType | None
513
+ ) -> None:
514
+ if os.name != "posix":
515
+ return
516
+ if not self._enabled or self._old is None:
517
+ return
518
+ try:
519
+ termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self._old)
520
+ except OSError:
521
+ pass
522
+
523
+ def _read_keys_nonblocking() -> list[str]:
524
+ """
525
+ Read available keys without blocking.
526
+ Returns a list of key tokens: "LEFT", "RIGHT", "q", etc.
527
+ """
528
+ keys: list[str] = []
529
+
530
+ if os.name == "nt":
531
+ raise NotImplementedError()
532
+ # try:
533
+ # import msvcrt
534
+ # while msvcrt.kbhit():
535
+ # ch = msvcrt.getwch()
536
+ # if ch in ("\x00", "\xe0"): # special key prefix
537
+ # ch2 = msvcrt.getwch()
538
+ # if ch2 == "K":
539
+ # keys.append("LEFT")
540
+ # elif ch2 == "M":
541
+ # keys.append("RIGHT")
542
+ # elif ch2 == "H":
543
+ # keys.append("UP")
544
+ # elif ch2 == "P":
545
+ # keys.append("DOWN")
546
+ # else:
547
+ # keys.append(ch)
548
+ # except Exception:
549
+ # return keys
550
+ # return keys
551
+
552
+ # POSIX: stdin will be readable via selectors. We still decode escape sequences here.
553
+ try:
554
+ data = os.read(sys.stdin.fileno(), 64)
555
+ except OSError:
556
+ return keys
557
+
558
+ if not data:
559
+ return keys
560
+
561
+ s = data.decode("utf-8", "ignore")
562
+ i = 0
563
+ while i < len(s):
564
+ ch = s[i]
565
+ if ch == "\x1b": # escape
566
+ # Arrow keys: ESC [ D/C
567
+ if i + 2 < len(s) and s[i + 1] == "[":
568
+ code = s[i + 2]
569
+ if code == "D":
570
+ keys.append("LEFT")
571
+ i += 3
572
+ continue
573
+ if code == "C":
574
+ keys.append("RIGHT")
575
+ i += 3
576
+ continue
577
+ if code == "A":
578
+ keys.append("UP")
579
+ i += 3
580
+ continue
581
+ if code == "B":
582
+ keys.append("DOWN")
583
+ i += 3
584
+ continue
585
+ i += 1
586
+ continue
587
+ keys.append(ch)
588
+ i += 1
589
+
590
+ return keys
591
+
592
+ # -----------------------------
593
+ # Viewer state
594
+ # -----------------------------
595
+
596
+ @dataclass
597
+ class AdapterProperties:
598
+ """Adapter state class"""
599
+ blocks : deque[Text] | None
600
+ style : str
601
+ last_write_timestamp: float | None = None
602
+
603
+
604
+ # -----------------------------
605
+ # Main loop
606
+ # -----------------------------
607
+
608
+ DEFAULT_MAX_EVENTS = 1000
609
+
610
+ def main(argv: list[str] | None = None) -> None:
611
+ """Main syndesi-trace entry-point"""
612
+
613
+ parser = argparse.ArgumentParser(
614
+ prog="syndesi-trace",
615
+ description="Live console viewer for Syndesi trace events (UDP)."
616
+ )
617
+
618
+ parser.add_argument(
619
+ "--mode",
620
+ choices=[str(x) for x in TraceMode],
621
+ default=TraceMode.INTERACTIVE.value,
622
+ help="Display mode. interactive: tabs per adapter. flat: append-only timeline.",
623
+ )
624
+
625
+ # Network
626
+ parser.add_argument(
627
+ "--port",
628
+ type=int,
629
+ default=DEFAULT_MULTICAST_PORT,
630
+ help="UDP port to bind (default: 12000)."
631
+ )
632
+ parser.add_argument(
633
+ "--group",
634
+ default=DEFAULT_MULTICAST_GROUP,
635
+ help="If set, join this UDP multicast group "\
636
+ "(enables true pub/sub without producer client management).",
637
+ )
638
+
639
+ # Filtering
640
+ parser.add_argument(
641
+ "--filter",
642
+ default="*",
643
+ type=str,
644
+ help="Filter which adapters are displayed"
645
+ )
646
+
647
+ # Time
648
+ parser.add_argument(
649
+ "--time",
650
+ dest="time_mode",
651
+ choices=[str(x) for x in TimeMode],
652
+ default=TimeMode.RELATIVE.value,
653
+ help="Time display: rel = relative to adapter open/first seen, "\
654
+ "abs = absolute timestamp")
655
+
656
+ # Content verbosity
657
+ parser.add_argument(
658
+ "--no-frag",
659
+ action="store_true",
660
+ help="Disable fragment display"
661
+ )
662
+ parser.add_argument(
663
+ "--max-events",
664
+ type=int,
665
+ default=DEFAULT_MAX_EVENTS,
666
+ help="Max event blocks kept per adapter in interactive mode (0 = unlimited)."
667
+ )
668
+
669
+ # Output (flat)
670
+ parser.add_argument(
671
+ "--output",
672
+ default=None,
673
+ help="Write flat output to this file instead of stdout."
674
+ )
675
+
676
+ args = parser.parse_args(argv)
677
+
678
+ mode = TraceMode(args.mode)
679
+
680
+ trace : Trace
681
+
682
+ if mode == TraceMode.INTERACTIVE:
683
+ trace = InteractiveTrace(
684
+ group=args.group,
685
+ port=args.port,
686
+ time_mode=args.time_mode,
687
+ descriptor_filter=args.filter,
688
+ no_fragments=args.no_frag,
689
+ max_events=args.max_events
690
+ )
691
+ elif mode == TraceMode.FLAT:
692
+ trace = FlatTrace(
693
+ group=args.group,
694
+ port=args.port,
695
+ time_mode=args.time_mode,
696
+ descriptor_filter=args.filter,
697
+ no_fragments=args.no_frag,
698
+ output=args.output
699
+ )
700
+ else:
701
+ raise ValueError(f'Invalid mode : {mode}')
702
+
703
+ try:
704
+ trace.run()
705
+ except KeyboardInterrupt:
706
+ pass
707
+ finally:
708
+ trace.close()
709
+
710
+ if __name__ == "__main__":
711
+ main()