shrinkray 25.12.27.2__py3-none-any.whl → 25.12.28.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.
shrinkray/tui.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """Textual-based TUI for Shrink Ray."""
2
2
 
3
3
  import os
4
+ import time
4
5
  import traceback
5
6
  from collections.abc import AsyncGenerator
6
7
  from contextlib import aclosing
@@ -11,14 +12,18 @@ import humanize
11
12
  from rich.text import Text
12
13
  from textual import work
13
14
  from textual.app import App, ComposeResult
14
- from textual.containers import Vertical, VerticalScroll
15
+ from textual.containers import Horizontal, Vertical, VerticalScroll
15
16
  from textual.reactive import reactive
16
17
  from textual.screen import ModalScreen
17
18
  from textual.theme import Theme
18
19
  from textual.widgets import DataTable, Footer, Header, Label, Static
19
20
 
20
21
  from shrinkray.subprocess.client import SubprocessClient
21
- from shrinkray.subprocess.protocol import PassStatsData, ProgressUpdate, Response
22
+ from shrinkray.subprocess.protocol import (
23
+ PassStatsData,
24
+ ProgressUpdate,
25
+ Response,
26
+ )
22
27
 
23
28
 
24
29
  ThemeMode = Literal["auto", "dark", "light"]
@@ -229,8 +234,6 @@ class ContentPreview(Static):
229
234
  _pending_hex_mode: bool = False
230
235
 
231
236
  def update_content(self, content: str, hex_mode: bool) -> None:
232
- import time
233
-
234
237
  # Store the pending content
235
238
  self._pending_content = content
236
239
  self._pending_hex_mode = hex_mode
@@ -305,6 +308,67 @@ class ContentPreview(Static):
305
308
  )
306
309
 
307
310
 
311
+ class OutputPreview(Static):
312
+ """Widget to display test output preview."""
313
+
314
+ output_content = reactive("")
315
+ active_test_id: reactive[int | None] = reactive(None)
316
+ _last_update_time: float = 0.0
317
+ _last_seen_test_id: int | None = None # Track last test ID for "completed" message
318
+
319
+ def update_output(self, content: str, test_id: int | None) -> None:
320
+ # Throttle updates to every 200ms
321
+ now = time.time()
322
+ if now - self._last_update_time < 0.2:
323
+ return
324
+
325
+ self._last_update_time = now
326
+ self.output_content = content
327
+ # Track the last test ID we've seen (for showing in "completed" message)
328
+ if test_id is not None:
329
+ self._last_seen_test_id = test_id
330
+ self.active_test_id = test_id
331
+ self.refresh(layout=True)
332
+
333
+ def _get_available_lines(self) -> int:
334
+ """Get the number of lines available for display based on container size."""
335
+ try:
336
+ parent = self.parent
337
+ if parent and hasattr(parent, "size"):
338
+ parent_size = parent.size # type: ignore[union-attr]
339
+ if parent_size.height > 0:
340
+ return max(10, parent_size.height - 3)
341
+ if self.app and self.app.size.height > 0:
342
+ return max(10, self.app.size.height - 15)
343
+ except Exception:
344
+ pass
345
+ return 30
346
+
347
+ def render(self) -> str:
348
+ # Header line
349
+ if self.active_test_id is not None:
350
+ header = f"[green]Test #{self.active_test_id} running...[/green]"
351
+ elif self.output_content and self._last_seen_test_id is not None:
352
+ header = f"[dim]Test #{self._last_seen_test_id} completed[/dim]"
353
+ else:
354
+ header = "[dim]No test output yet...[/dim]"
355
+
356
+ if not self.output_content:
357
+ return header
358
+
359
+ available_lines = self._get_available_lines()
360
+ lines = self.output_content.split("\n")
361
+
362
+ # Show tail of output (most recent lines)
363
+ if len(lines) <= available_lines:
364
+ return f"{header}\n{self.output_content}"
365
+
366
+ # Truncate from the beginning
367
+ truncated_lines = lines[-(available_lines):]
368
+ skipped = len(lines) - available_lines
369
+ return f"{header}\n... ({skipped} earlier lines)\n" + "\n".join(truncated_lines)
370
+
371
+
308
372
  class HelpScreen(ModalScreen[None]):
309
373
  """Modal screen showing keyboard shortcuts help."""
310
374
 
@@ -595,10 +659,32 @@ class ShrinkRayApp(App[None]):
595
659
  margin: 0 1;
596
660
  }
597
661
 
662
+ #content-area {
663
+ height: 1fr;
664
+ }
665
+
598
666
  #content-container {
599
667
  border: solid blue;
600
668
  margin: 1;
601
- height: 1fr;
669
+ padding: 1;
670
+ width: 1fr;
671
+ height: 100%;
672
+ }
673
+
674
+ #content-container:dark {
675
+ border: solid lightskyblue;
676
+ }
677
+
678
+ #output-container {
679
+ border: solid blue;
680
+ margin: 1;
681
+ padding: 1;
682
+ width: 1fr;
683
+ height: 100%;
684
+ }
685
+
686
+ #output-container:dark {
687
+ border: solid lightskyblue;
602
688
  }
603
689
  """
604
690
 
@@ -661,8 +747,13 @@ class ShrinkRayApp(App[None]):
661
747
  )
662
748
  with Vertical(id="stats-container"):
663
749
  yield StatsDisplay(id="stats-display")
664
- with VerticalScroll(id="content-container"):
665
- yield ContentPreview(id="content-preview")
750
+ with Horizontal(id="content-area"):
751
+ with VerticalScroll(id="content-container") as content_scroll:
752
+ content_scroll.border_title = "Recent Reductions"
753
+ yield ContentPreview(id="content-preview")
754
+ with VerticalScroll(id="output-container") as output_scroll:
755
+ output_scroll.border_title = "Test Output"
756
+ yield OutputPreview(id="output-preview")
666
757
  yield Footer()
667
758
 
668
759
  async def on_mount(self) -> None:
@@ -695,7 +786,7 @@ class ShrinkRayApp(App[None]):
695
786
 
696
787
  await self._client.start()
697
788
 
698
- # Start the reduction
789
+ # Start the reduction - validation was already done by main()
699
790
  response = await self._client.start_reduction(
700
791
  file_path=self._file_path,
701
792
  test=self._test,
@@ -709,6 +800,7 @@ class ShrinkRayApp(App[None]):
709
800
  no_clang_delta=self._no_clang_delta,
710
801
  clang_delta=self._clang_delta,
711
802
  trivial_is_error=self._trivial_is_error,
803
+ skip_validation=True,
712
804
  )
713
805
 
714
806
  if response.error:
@@ -719,6 +811,7 @@ class ShrinkRayApp(App[None]):
719
811
  # Monitor progress (client is already started and reduction is running)
720
812
  stats_display = self.query_one("#stats-display", StatsDisplay)
721
813
  content_preview = self.query_one("#content-preview", ContentPreview)
814
+ output_preview = self.query_one("#output-preview", OutputPreview)
722
815
 
723
816
  async with aclosing(self._client.get_progress_updates()) as updates:
724
817
  async for update in updates:
@@ -726,6 +819,9 @@ class ShrinkRayApp(App[None]):
726
819
  content_preview.update_content(
727
820
  update.content_preview, update.hex_mode
728
821
  )
822
+ output_preview.update_output(
823
+ update.test_output_preview, update.active_test_id
824
+ )
729
825
  self._latest_pass_stats = update.pass_stats
730
826
  self._current_pass_name = update.current_pass_name
731
827
  self._disabled_passes = update.disabled_passes
@@ -741,7 +837,10 @@ class ShrinkRayApp(App[None]):
741
837
  # Check if there was an error from the worker
742
838
  if self._client.error_message:
743
839
  # Exit immediately on error, printing the error message
744
- self.exit(return_code=1, message=f"Error: {self._client.error_message}")
840
+ self.exit(
841
+ return_code=1,
842
+ message=f"Error: {self._client.error_message}",
843
+ )
745
844
  return
746
845
  elif self._exit_on_completion:
747
846
  self.exit()
@@ -804,54 +903,6 @@ class ShrinkRayApp(App[None]):
804
903
  return self._completed
805
904
 
806
905
 
807
- async def _validate_initial_example(
808
- file_path: str,
809
- test: list[str],
810
- parallelism: int | None,
811
- timeout: float | None,
812
- seed: int,
813
- input_type: str,
814
- in_place: bool,
815
- formatter: str,
816
- volume: str,
817
- no_clang_delta: bool,
818
- clang_delta: str,
819
- trivial_is_error: bool,
820
- ) -> str | None:
821
- """Validate initial example before showing TUI.
822
-
823
- Returns error_message if validation failed, None if it passed.
824
- """
825
- debug_mode = volume == "debug"
826
- client = SubprocessClient(debug_mode=debug_mode)
827
- try:
828
- await client.start()
829
-
830
- response = await client.start_reduction(
831
- file_path=file_path,
832
- test=test,
833
- parallelism=parallelism,
834
- timeout=timeout,
835
- seed=seed,
836
- input_type=input_type,
837
- in_place=in_place,
838
- formatter=formatter,
839
- volume=volume,
840
- no_clang_delta=no_clang_delta,
841
- clang_delta=clang_delta,
842
- trivial_is_error=trivial_is_error,
843
- )
844
-
845
- if response.error:
846
- return response.error
847
-
848
- # Validation passed - cancel this reduction since TUI will start fresh
849
- await client.cancel()
850
- return None
851
- finally:
852
- await client.close()
853
-
854
-
855
906
  def run_textual_ui(
856
907
  file_path: str,
857
908
  test: list[str],
@@ -868,43 +919,14 @@ def run_textual_ui(
868
919
  exit_on_completion: bool = True,
869
920
  theme: ThemeMode = "auto",
870
921
  ) -> None:
871
- """Run the textual TUI."""
872
- import asyncio
873
- import sys
874
-
875
- print("Validating initial example...", flush=True)
876
-
877
- # Validate initial example before showing TUI
878
- async def validate():
879
- return await _validate_initial_example(
880
- file_path=file_path,
881
- test=test,
882
- parallelism=parallelism,
883
- timeout=timeout,
884
- seed=seed,
885
- input_type=input_type,
886
- in_place=in_place,
887
- formatter=formatter,
888
- volume=volume,
889
- no_clang_delta=no_clang_delta,
890
- clang_delta=clang_delta,
891
- trivial_is_error=trivial_is_error,
892
- )
922
+ """Run the textual TUI.
893
923
 
894
- try:
895
- error = asyncio.run(validate())
896
- except Exception as e:
897
- import traceback
898
-
899
- traceback.print_exc()
900
- print(f"Error: {e}", file=sys.stderr)
901
- sys.exit(1)
902
-
903
- if error:
904
- print(f"Error: {error}", file=sys.stderr)
905
- sys.exit(1)
924
+ Note: Validation must be done before calling this function.
925
+ The caller (main()) is responsible for running run_validation() first.
926
+ """
927
+ import sys
906
928
 
907
- # Validation passed - now show the TUI which will start a fresh client
929
+ # Start the TUI app - validation has already been done by main()
908
930
  app = ShrinkRayApp(
909
931
  file_path=file_path,
910
932
  test=test,
@@ -0,0 +1,403 @@
1
+ """Initial validation of interestingness tests before reduction.
2
+
3
+ This module provides validation that runs in the main process using trio,
4
+ before the TUI is launched. It prints commands and temporary directories
5
+ to stderr so users can understand what's happening with slow tests, and
6
+ preserves temporary directories on failure for debugging.
7
+ """
8
+
9
+ import io
10
+ import os
11
+ import shlex
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+ import traceback
17
+ from dataclasses import dataclass
18
+
19
+ import trio
20
+
21
+ from shrinkray.cli import InputType
22
+
23
+
24
+ @dataclass
25
+ class ValidationResult:
26
+ """Result of initial validation."""
27
+
28
+ success: bool
29
+ error_message: str | None = None
30
+ exit_code: int | None = None
31
+ # Temp directories to clean up only on success
32
+ temp_dirs: list[str] | None = None
33
+ # Whether formatter is usable (None if no formatter specified)
34
+ formatter_works: bool | None = None
35
+
36
+
37
+ def _build_command(
38
+ test: list[str],
39
+ working_file: str,
40
+ input_type: InputType,
41
+ ) -> list[str]:
42
+ """Build the command to run, adding test file path if needed."""
43
+ if input_type.enabled(InputType.arg):
44
+ return test + [working_file]
45
+ return list(test)
46
+
47
+
48
+ def _format_command_for_display(command: list[str], cwd: str) -> str:
49
+ """Format a command for display, with cd on its own line and relative paths.
50
+
51
+ Returns a multi-line string with:
52
+ - cd <directory>
53
+ - <command with relative paths for files in cwd>
54
+ """
55
+ # Convert absolute paths within cwd to relative paths for readability
56
+ display_parts = []
57
+ for part in command:
58
+ if part.startswith(cwd + os.sep):
59
+ # Convert to relative path
60
+ display_parts.append(os.path.relpath(part, cwd))
61
+ else:
62
+ display_parts.append(part)
63
+
64
+ quoted = " ".join(shlex.quote(part) for part in display_parts)
65
+ return f"cd {shlex.quote(cwd)}\n{quoted}"
66
+
67
+
68
+ async def _run_validation_test(
69
+ test: list[str],
70
+ initial_content: bytes,
71
+ base: str,
72
+ input_type: InputType,
73
+ in_place: bool,
74
+ filename: str,
75
+ ) -> ValidationResult:
76
+ """Run the interestingness test once and check if it passes.
77
+
78
+ Returns ValidationResult with success=True if the test passed (exit code 0),
79
+ or success=False with error details if it failed.
80
+ """
81
+ temp_dirs: list[str] = []
82
+
83
+ try:
84
+ # Determine working directory and file path
85
+ if in_place:
86
+ if input_type == InputType.basename:
87
+ working = filename
88
+ cwd = os.getcwd()
89
+ # Write directly to original file
90
+ async with await trio.open_file(working, "wb") as f:
91
+ await f.write(initial_content)
92
+ else:
93
+ # Create a temp file in same directory with random suffix
94
+ base_name, ext = os.path.splitext(filename)
95
+ working = base_name + "-" + os.urandom(16).hex() + ext
96
+ cwd = os.getcwd()
97
+ async with await trio.open_file(working, "wb") as f:
98
+ await f.write(initial_content)
99
+ temp_dirs.append(working) # Track for cleanup
100
+ else:
101
+ # Create a temporary directory
102
+ temp_dir = tempfile.mkdtemp(prefix="shrinkray-validate-")
103
+ temp_dirs.append(temp_dir)
104
+ working = os.path.join(temp_dir, base)
105
+ cwd = temp_dir
106
+ async with await trio.open_file(working, "wb") as f:
107
+ await f.write(initial_content)
108
+
109
+ # Build command
110
+ command = _build_command(test, working, input_type)
111
+
112
+ # Print what we're doing to stderr
113
+ print(
114
+ "\nRunning interestingness test:",
115
+ file=sys.stderr,
116
+ flush=True,
117
+ )
118
+ print(
119
+ _format_command_for_display(command, cwd),
120
+ file=sys.stderr,
121
+ flush=True,
122
+ )
123
+ print(file=sys.stderr, flush=True)
124
+
125
+ # Handle stdin if needed
126
+ stdin_data: bytes | None = None
127
+ if input_type.enabled(InputType.stdin) and not os.path.isdir(working):
128
+ with open(working, "rb") as f:
129
+ stdin_data = f.read()
130
+
131
+ # Run subprocess with real-time output streaming
132
+ # We use subprocess.run in a thread because trio.run_process doesn't
133
+ # properly support file descriptor inheritance for streaming output.
134
+ def run_subprocess() -> subprocess.CompletedProcess[bytes]:
135
+ # Try to stream output directly to stderr if possible
136
+ # This allows real-time output visibility for slow tests
137
+ try:
138
+ stderr_fd = sys.stderr.fileno()
139
+ return subprocess.run(
140
+ command,
141
+ cwd=cwd,
142
+ stdin=subprocess.DEVNULL if stdin_data is None else None,
143
+ stdout=stderr_fd,
144
+ stderr=stderr_fd,
145
+ input=stdin_data,
146
+ check=False,
147
+ )
148
+ except (io.UnsupportedOperation, OSError):
149
+ # Falls back to capturing if stderr doesn't have a real file
150
+ # descriptor (e.g., when running under pytest with capture)
151
+ return subprocess.run(
152
+ command,
153
+ cwd=cwd,
154
+ stdin=subprocess.DEVNULL if stdin_data is None else None,
155
+ stdout=subprocess.PIPE,
156
+ stderr=subprocess.PIPE,
157
+ input=stdin_data,
158
+ check=False,
159
+ )
160
+
161
+ result = await trio.to_thread.run_sync(run_subprocess)
162
+
163
+ # If we captured output (fallback mode), print it now
164
+ if result.stdout:
165
+ sys.stderr.buffer.write(result.stdout)
166
+ sys.stderr.flush()
167
+ if result.stderr:
168
+ sys.stderr.buffer.write(result.stderr)
169
+ sys.stderr.flush()
170
+
171
+ print(file=sys.stderr, flush=True)
172
+ print(
173
+ f"Exit code: {result.returncode}",
174
+ file=sys.stderr,
175
+ flush=True,
176
+ )
177
+
178
+ if result.returncode != 0:
179
+ return ValidationResult(
180
+ success=False,
181
+ error_message=(
182
+ f"Interestingness test exited with code {result.returncode}, "
183
+ f"but should return 0 for interesting test cases.\n\n"
184
+ f"To reproduce:\n{_format_command_for_display(command, cwd)}"
185
+ ),
186
+ exit_code=result.returncode,
187
+ temp_dirs=temp_dirs,
188
+ )
189
+
190
+ return ValidationResult(
191
+ success=True,
192
+ exit_code=0,
193
+ temp_dirs=temp_dirs,
194
+ )
195
+
196
+ except Exception as e:
197
+ traceback.print_exc()
198
+ return ValidationResult(
199
+ success=False,
200
+ error_message=f"Error running interestingness test: {e}",
201
+ temp_dirs=temp_dirs,
202
+ )
203
+
204
+
205
+ async def _run_formatter(
206
+ formatter_command: list[str],
207
+ content: bytes,
208
+ ) -> subprocess.CompletedProcess[bytes]:
209
+ """Run the formatter command on content, streaming output to stderr."""
210
+
211
+ print("\nRunning formatter:", file=sys.stderr, flush=True)
212
+ print(
213
+ " ".join(shlex.quote(part) for part in formatter_command),
214
+ file=sys.stderr,
215
+ flush=True,
216
+ )
217
+
218
+ def run_subprocess() -> subprocess.CompletedProcess[bytes]:
219
+ return subprocess.run(
220
+ formatter_command,
221
+ input=content,
222
+ capture_output=True,
223
+ check=False,
224
+ )
225
+
226
+ result = await trio.to_thread.run_sync(run_subprocess)
227
+
228
+ # Show stderr from formatter if any
229
+ if result.stderr:
230
+ sys.stderr.buffer.write(result.stderr)
231
+ sys.stderr.flush()
232
+
233
+ print(
234
+ f"Formatter exit code: {result.returncode}",
235
+ file=sys.stderr,
236
+ flush=True,
237
+ )
238
+
239
+ return result
240
+
241
+
242
+ async def validate_initial_example(
243
+ file_path: str,
244
+ test: list[str],
245
+ input_type: InputType,
246
+ in_place: bool,
247
+ formatter_command: list[str] | None = None,
248
+ ) -> ValidationResult:
249
+ """Validate that the initial example passes the interestingness test.
250
+
251
+ This runs directly in the main process using trio, streaming output
252
+ to stderr so users can see progress for slow tests. Also checks the
253
+ formatter if one is specified.
254
+
255
+ Args:
256
+ file_path: Path to the file to reduce
257
+ test: The interestingness test command
258
+ input_type: How to pass input to the test
259
+ in_place: Whether to run in the current directory
260
+ formatter_command: Optional formatter command to validate
261
+
262
+ Returns:
263
+ ValidationResult indicating success or failure with details.
264
+ On failure, temp_dirs are preserved for debugging.
265
+ """
266
+ # Read the initial content
267
+ if os.path.isdir(file_path):
268
+ # For directories, we need different handling
269
+ # For now, just validate that it's a valid directory
270
+ return ValidationResult(success=True)
271
+
272
+ with open(file_path, "rb") as f:
273
+ initial_content = f.read()
274
+
275
+ base = os.path.basename(file_path)
276
+
277
+ print("Validating interestingness test...", file=sys.stderr, flush=True)
278
+
279
+ result = await _run_validation_test(
280
+ test=test,
281
+ initial_content=initial_content,
282
+ base=base,
283
+ input_type=input_type,
284
+ in_place=in_place,
285
+ filename=file_path,
286
+ )
287
+
288
+ if not result.success:
289
+ # On failure, keep temp directories and tell user
290
+ if result.temp_dirs:
291
+ print(
292
+ "\nTemporary files preserved for debugging:",
293
+ file=sys.stderr,
294
+ flush=True,
295
+ )
296
+ for path in result.temp_dirs:
297
+ print(f" {path}", file=sys.stderr, flush=True)
298
+ return result
299
+
300
+ # Clean up temp directories from initial test
301
+ if result.temp_dirs:
302
+ for path in result.temp_dirs:
303
+ try:
304
+ if os.path.isdir(path):
305
+ shutil.rmtree(path)
306
+ elif os.path.exists(path):
307
+ os.unlink(path)
308
+ except Exception:
309
+ pass # Best effort cleanup
310
+
311
+ print("Initial validation passed.", file=sys.stderr, flush=True)
312
+
313
+ # Now check formatter if specified
314
+ formatter_works: bool | None = None
315
+ if formatter_command is not None:
316
+ formatter_result = await _run_formatter(formatter_command, initial_content)
317
+
318
+ if formatter_result.returncode != 0:
319
+ return ValidationResult(
320
+ success=False,
321
+ error_message=(
322
+ "Formatter exited unexpectedly on initial test case. "
323
+ "If this is expected, please run with --formatter=none.\n\n"
324
+ f"Formatter stderr:\n{formatter_result.stderr.decode('utf-8', errors='replace').strip()}"
325
+ ),
326
+ exit_code=formatter_result.returncode,
327
+ )
328
+
329
+ reformatted = formatter_result.stdout
330
+
331
+ # If formatter changed the content, verify it's still interesting
332
+ if reformatted != initial_content:
333
+ print(
334
+ "\nChecking if formatted version is still interesting...",
335
+ file=sys.stderr,
336
+ flush=True,
337
+ )
338
+ formatted_result = await _run_validation_test(
339
+ test=test,
340
+ initial_content=reformatted,
341
+ base=base,
342
+ input_type=input_type,
343
+ in_place=in_place,
344
+ filename=file_path,
345
+ )
346
+
347
+ # Clean up temp dirs from formatted test
348
+ if formatted_result.temp_dirs:
349
+ for path in formatted_result.temp_dirs:
350
+ try:
351
+ if os.path.isdir(path):
352
+ shutil.rmtree(path)
353
+ elif os.path.exists(path):
354
+ os.unlink(path)
355
+ except Exception:
356
+ pass
357
+
358
+ if not formatted_result.success:
359
+ return ValidationResult(
360
+ success=False,
361
+ error_message=(
362
+ "Formatting initial test case made it uninteresting. "
363
+ "If this is expected, please run with --formatter=none.\n\n"
364
+ f"Formatter stderr:\n{formatter_result.stderr.decode('utf-8', errors='replace').strip()}"
365
+ ),
366
+ exit_code=formatted_result.exit_code,
367
+ )
368
+
369
+ print("Formatted version is also interesting.", file=sys.stderr, flush=True)
370
+
371
+ formatter_works = True
372
+
373
+ return ValidationResult(
374
+ success=True,
375
+ exit_code=0,
376
+ formatter_works=formatter_works,
377
+ )
378
+
379
+
380
+ def run_validation(
381
+ file_path: str,
382
+ test: list[str],
383
+ input_type: InputType,
384
+ in_place: bool,
385
+ formatter_command: list[str] | None = None,
386
+ ) -> ValidationResult:
387
+ """Run initial validation synchronously using trio.run().
388
+
389
+ This is the main entry point for validation from the CLI/TUI.
390
+ It runs validation directly in the main process before any asyncio
391
+ event loop is started.
392
+ """
393
+
394
+ async def _run() -> ValidationResult:
395
+ return await validate_initial_example(
396
+ file_path,
397
+ test,
398
+ input_type,
399
+ in_place,
400
+ formatter_command,
401
+ )
402
+
403
+ return trio.run(_run)