wafer-core 0.1.37__py3-none-any.whl → 0.1.39__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.
Files changed (32) hide show
  1. wafer_core/lib/trace_compare/fusion_analyzer.py +2 -0
  2. wafer_core/rollouts/_logging/__init__.py +5 -1
  3. wafer_core/rollouts/_logging/logging_config.py +95 -3
  4. wafer_core/rollouts/_logging/sample_handler.py +66 -0
  5. wafer_core/rollouts/_pytui/__init__.py +114 -0
  6. wafer_core/rollouts/_pytui/app.py +809 -0
  7. wafer_core/rollouts/_pytui/console.py +291 -0
  8. wafer_core/rollouts/_pytui/renderer.py +210 -0
  9. wafer_core/rollouts/_pytui/spinner.py +73 -0
  10. wafer_core/rollouts/_pytui/terminal.py +489 -0
  11. wafer_core/rollouts/_pytui/text.py +470 -0
  12. wafer_core/rollouts/_pytui/theme.py +241 -0
  13. wafer_core/rollouts/evaluation.py +142 -177
  14. wafer_core/rollouts/progress_app.py +395 -0
  15. wafer_core/rollouts/tui/DESIGN.md +251 -115
  16. wafer_core/rollouts/tui/monitor.py +64 -20
  17. wafer_core/tools/compile/__init__.py +30 -0
  18. wafer_core/tools/compile/compiler.py +314 -0
  19. wafer_core/tools/compile/modal_compile.py +359 -0
  20. wafer_core/tools/compile/tests/__init__.py +1 -0
  21. wafer_core/tools/compile/tests/test_compiler.py +675 -0
  22. wafer_core/tools/compile/tests/test_data/utils.cuh +10 -0
  23. wafer_core/tools/compile/tests/test_data/vector_add.cu +7 -0
  24. wafer_core/tools/compile/tests/test_data/with_header.cu +9 -0
  25. wafer_core/tools/compile/tests/test_modal_integration.py +326 -0
  26. wafer_core/tools/compile/types.py +117 -0
  27. {wafer_core-0.1.37.dist-info → wafer_core-0.1.39.dist-info}/METADATA +1 -1
  28. {wafer_core-0.1.37.dist-info → wafer_core-0.1.39.dist-info}/RECORD +29 -12
  29. wafer_core/rollouts/events.py +0 -240
  30. wafer_core/rollouts/progress_display.py +0 -476
  31. wafer_core/utils/event_streaming.py +0 -63
  32. {wafer_core-0.1.37.dist-info → wafer_core-0.1.39.dist-info}/WHEEL +0 -0
@@ -0,0 +1,291 @@
1
+ """Console: unified output manager coordinating spinners with logging.
2
+
3
+ The problem: Spinner animations and log messages both write to stderr.
4
+ When they interleave, output gets corrupted. Console solves this by:
5
+ 1. Owning the spinner lifecycle
6
+ 2. Providing a logging handler that pauses the spinner before emitting
7
+
8
+ Usage:
9
+ import logging
10
+ from pytui import Console
11
+
12
+ logger = logging.getLogger(__name__)
13
+ console = Console()
14
+ console.install_logging_handler(logger)
15
+
16
+ with console.spinner("Provisioning..."):
17
+ # Any logger.info/warning/error calls here will:
18
+ # 1. Pause the spinner (clear its line)
19
+ # 2. Print the log message on its own line
20
+ # 3. Resume the spinner below
21
+ result = do_stuff_that_logs()
22
+ # Shows: ✓ Provisioning...
23
+
24
+ Three levels of granularity (Casey Muratori's rule: no API holes):
25
+ - Low-level: pause_spinner(), resume_spinner(), write()
26
+ - Mid-level: status() for one-off messages
27
+ - High-level: spinner() context manager
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import itertools
33
+ import logging
34
+ import sys
35
+ import threading
36
+ import time
37
+ from collections.abc import Iterator
38
+ from contextlib import contextmanager
39
+ from typing import TextIO
40
+
41
+ BRAILLE_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
42
+
43
+
44
+ class Console:
45
+ """Unified output manager coordinating spinners with logging.
46
+
47
+ Args:
48
+ output: Stream to write to. Defaults to stderr (matches logging convention).
49
+ spinner_interval: Seconds between spinner frame updates.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ output: TextIO = sys.stderr,
55
+ spinner_interval: float = 0.08,
56
+ ) -> None:
57
+ self._output = output
58
+ self._spinner_interval = spinner_interval
59
+ self._lock = threading.Lock()
60
+
61
+ # Active spinner state (immediate mode: only one at a time)
62
+ self._spinner_message: str | None = None
63
+ self._spinner_frame_iter: Iterator[str] | None = None
64
+ self._spinner_stop: threading.Event | None = None
65
+ self._spinner_thread: threading.Thread | None = None
66
+ self._spinner_paused: bool = False
67
+ self._spinner_start_time: float | None = None
68
+
69
+ # Installed logging handlers (for cleanup)
70
+ self._handlers: list[tuple[logging.Logger, logging.Handler]] = []
71
+
72
+ # ─── Low-level API ─────────────────────────────────────────────────────
73
+
74
+ def write(self, text: str) -> None:
75
+ """Write text to output stream. Thread-safe."""
76
+ with self._lock:
77
+ self._output.write(text)
78
+ self._output.flush()
79
+
80
+ def write_line(self, text: str) -> None:
81
+ """Write a complete line to output. Coordinates with active spinner."""
82
+ with self._lock:
83
+ if self._spinner_message is not None and not self._spinner_paused:
84
+ # Clear spinner line, write message, redraw spinner
85
+ self._output.write(f"\r\033[K{text}\n")
86
+ frame = next(self._spinner_frame_iter) if self._spinner_frame_iter else "⠋"
87
+ self._output.write(f"{frame} {self._spinner_message}")
88
+ self._output.flush()
89
+ else:
90
+ self._output.write(f"{text}\n")
91
+ self._output.flush()
92
+
93
+ def pause_spinner(self) -> None:
94
+ """Pause the spinner animation and clear its line.
95
+
96
+ Used by ConsoleHandler before emitting log records.
97
+ """
98
+ with self._lock:
99
+ if self._spinner_message is not None and not self._spinner_paused:
100
+ self._spinner_paused = True
101
+ self._output.write("\r\033[K")
102
+ self._output.flush()
103
+
104
+ def resume_spinner(self) -> None:
105
+ """Resume the spinner animation.
106
+
107
+ Used by ConsoleHandler after emitting log records.
108
+ """
109
+ with self._lock:
110
+ if self._spinner_message is not None and self._spinner_paused:
111
+ self._spinner_paused = False
112
+ frame = next(self._spinner_frame_iter) if self._spinner_frame_iter else "⠋"
113
+ self._output.write(f"{frame} {self._spinner_message}")
114
+ self._output.flush()
115
+
116
+ # ─── Mid-level API ─────────────────────────────────────────────────────
117
+
118
+ def status(self, text: str, success: bool = True) -> None:
119
+ """Print a status line with ✓ or ✗ prefix."""
120
+ mark = "✓" if success else "✗"
121
+ self.write_line(f"{mark} {text}")
122
+
123
+ # ─── High-level API ────────────────────────────────────────────────────
124
+
125
+ @contextmanager
126
+ def spinner(self, message: str) -> Iterator[SpinnerHandle]:
127
+ """Context manager for spinner animation.
128
+
129
+ Any log messages emitted inside the block will be coordinated:
130
+ the spinner pauses, the message prints, the spinner resumes.
131
+
132
+ Args:
133
+ message: Text to display next to the spinner.
134
+
135
+ Yields:
136
+ SpinnerHandle with update() method to change the message.
137
+
138
+ Example:
139
+ with console.spinner("Loading...") as s:
140
+ s.update("Loading... 50%")
141
+ do_work()
142
+ # Shows: ✓ Loading... 50%
143
+ """
144
+ assert self._spinner_message is None, "Cannot nest spinners"
145
+
146
+ handle = SpinnerHandle(self)
147
+ self._spinner_message = message
148
+ self._spinner_frame_iter = itertools.cycle(BRAILLE_FRAMES)
149
+ self._spinner_stop = threading.Event()
150
+ self._spinner_paused = False
151
+ self._spinner_start_time = time.time()
152
+
153
+ self._spinner_thread = threading.Thread(target=self._spin_loop, daemon=True)
154
+ self._spinner_thread.start()
155
+
156
+ exc_info: BaseException | None = None
157
+ try:
158
+ yield handle
159
+ except BaseException as e:
160
+ exc_info = e
161
+ raise
162
+ finally:
163
+ # Stop spinner thread
164
+ assert self._spinner_stop is not None
165
+ self._spinner_stop.set()
166
+ if self._spinner_thread is not None:
167
+ self._spinner_thread.join()
168
+ self._spinner_thread = None
169
+
170
+ # Print final status with elapsed time
171
+ final_message = self._spinner_message
172
+ success = exc_info is None
173
+ mark = "✓" if success else "✗"
174
+ elapsed = self._format_elapsed(time.time() - self._spinner_start_time)
175
+
176
+ with self._lock:
177
+ self._output.write(f"\r\033[K{mark} {final_message} ({elapsed})\n")
178
+ self._output.flush()
179
+
180
+ # Clear spinner state
181
+ self._spinner_message = None
182
+ self._spinner_frame_iter = None
183
+ self._spinner_stop = None
184
+ self._spinner_paused = False
185
+ self._spinner_start_time = None
186
+
187
+ def _spin_loop(self) -> None:
188
+ """Background thread that animates the spinner."""
189
+ assert self._spinner_stop is not None
190
+ assert self._spinner_frame_iter is not None
191
+ assert self._spinner_start_time is not None
192
+
193
+ while not self._spinner_stop.is_set():
194
+ with self._lock:
195
+ if not self._spinner_paused and self._spinner_message is not None:
196
+ frame = next(self._spinner_frame_iter)
197
+ elapsed = self._format_elapsed(time.time() - self._spinner_start_time)
198
+ self._output.write(f"\r\033[K{frame} {self._spinner_message} ({elapsed})")
199
+ self._output.flush()
200
+ time.sleep(self._spinner_interval)
201
+
202
+ @staticmethod
203
+ def _format_elapsed(seconds: float) -> str:
204
+ """Format elapsed time as human-readable string (e.g., '1m7s', '45s')."""
205
+ seconds = int(seconds)
206
+ if seconds < 60:
207
+ return f"{seconds}s"
208
+ minutes = seconds // 60
209
+ secs = seconds % 60
210
+ if minutes < 60:
211
+ return f"{minutes}m{secs}s" if secs else f"{minutes}m"
212
+ hours = minutes // 60
213
+ mins = minutes % 60
214
+ if secs:
215
+ return f"{hours}h{mins}m{secs}s"
216
+ elif mins:
217
+ return f"{hours}h{mins}m"
218
+ else:
219
+ return f"{hours}h"
220
+
221
+ # ─── Logging Integration ───────────────────────────────────────────────
222
+
223
+ def install_logging_handler(
224
+ self,
225
+ logger: logging.Logger | None = None,
226
+ level: int = logging.DEBUG,
227
+ fmt: str = "[%(asctime)s] %(levelname)s: %(message)s",
228
+ datefmt: str = "%H:%M:%S",
229
+ ) -> logging.Handler:
230
+ """Install a logging handler that coordinates with the spinner.
231
+
232
+ Args:
233
+ logger: Logger to attach to. Defaults to root logger.
234
+ level: Minimum log level to handle.
235
+ fmt: Log message format string.
236
+ datefmt: Date format for %(asctime)s.
237
+
238
+ Returns:
239
+ The installed handler (for advanced use cases).
240
+ """
241
+ if logger is None:
242
+ logger = logging.getLogger()
243
+
244
+ handler = ConsoleHandler(self)
245
+ handler.setLevel(level)
246
+ handler.setFormatter(logging.Formatter(fmt, datefmt))
247
+ logger.addHandler(handler)
248
+
249
+ self._handlers.append((logger, handler))
250
+ return handler
251
+
252
+ def remove_logging_handlers(self) -> None:
253
+ """Remove all logging handlers installed by this Console."""
254
+ for logger, handler in self._handlers:
255
+ logger.removeHandler(handler)
256
+ self._handlers.clear()
257
+
258
+
259
+ class SpinnerHandle:
260
+ """Handle returned by Console.spinner() for updating the message."""
261
+
262
+ def __init__(self, console: Console) -> None:
263
+ self._console = console
264
+
265
+ def update(self, message: str) -> None:
266
+ """Update the spinner message."""
267
+ with self._console._lock:
268
+ self._console._spinner_message = message
269
+
270
+
271
+ class ConsoleHandler(logging.Handler):
272
+ """Logging handler that coordinates with Console's spinner.
273
+
274
+ When a log record is emitted:
275
+ 1. Pause the spinner (clear its line)
276
+ 2. Print the formatted log message
277
+ 3. Resume the spinner on a new line
278
+ """
279
+
280
+ def __init__(self, console: Console) -> None:
281
+ super().__init__()
282
+ self._console = console
283
+
284
+ def emit(self, record: logging.LogRecord) -> None:
285
+ try:
286
+ msg = self.format(record)
287
+ self._console.pause_spinner()
288
+ self._console.write_line(msg)
289
+ self._console.resume_spinner()
290
+ except Exception:
291
+ self.handleError(record)
@@ -0,0 +1,210 @@
1
+ """Differential terminal renderer.
2
+
3
+ Extracted from rollouts/frontends/tui/tui.py _do_render() - same algorithm.
4
+
5
+ Compares new lines against previous lines and only updates changed regions.
6
+ Uses synchronized output mode to prevent flicker.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections.abc import Callable
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ from .terminal import (
17
+ CLEAR_LINE_FULL,
18
+ CLEAR_SCREEN_HOME,
19
+ SYNC_OUTPUT_OFF,
20
+ SYNC_OUTPUT_ON,
21
+ Terminal,
22
+ )
23
+ from .text import truncate_to_width, visible_width
24
+
25
+ # Type for render logging callback
26
+ RenderLogFn = Callable[..., None] | None
27
+
28
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
29
+
30
+
31
+ def _strip_ansi(s: str) -> str:
32
+ return _ANSI_RE.sub("", s)
33
+
34
+
35
+ @dataclass
36
+ class RenderState:
37
+ """Tracks state between renders for differential updates."""
38
+
39
+ previous_lines: list[str] = field(default_factory=list)
40
+ previous_width: int = 0
41
+ cursor_row: int = 0 # 0-indexed, relative to first line
42
+ render_count: int = 0
43
+ log_fn: RenderLogFn = None
44
+ # Set by App before each render
45
+ msgs_batched: int = 0
46
+ msg_types_batched: dict = field(default_factory=dict)
47
+
48
+
49
+ def diff_render(
50
+ terminal: Terminal,
51
+ new_lines: list[str],
52
+ state: RenderState,
53
+ ) -> None:
54
+ """Render lines to terminal with differential updates.
55
+
56
+ Only redraws lines that changed since last render.
57
+ Uses synchronized output mode (\\x1b[?2026h/l) to prevent flicker.
58
+
59
+ Args:
60
+ terminal: Terminal to write to.
61
+ new_lines: Complete list of lines to display.
62
+ state: Mutable render state (updated in place).
63
+ """
64
+ width = terminal.columns
65
+ height = terminal.rows
66
+ state.render_count += 1
67
+ _rlog = state.log_fn
68
+
69
+ # Common log data for every render
70
+ def _emit_log(kind: str, buffer: str, **extra: Any) -> None:
71
+ if not _rlog:
72
+ return
73
+ line0 = _strip_ansi(new_lines[0])[:80] if new_lines else ""
74
+ _rlog(
75
+ "render",
76
+ n=state.render_count,
77
+ kind=kind,
78
+ width=width,
79
+ height=height,
80
+ lines=len(new_lines),
81
+ prev_lines=len(state.previous_lines),
82
+ cursor_row_before=state.cursor_row,
83
+ buf_bytes=len(buffer),
84
+ line0=line0,
85
+ msgs=state.msgs_batched,
86
+ msg_types=state.msg_types_batched or None,
87
+ **extra,
88
+ )
89
+
90
+ # Width changed - need full re-render
91
+ width_changed = state.previous_width != 0 and state.previous_width != width
92
+
93
+ # First render - just output everything
94
+ if len(state.previous_lines) == 0:
95
+ buffer = SYNC_OUTPUT_ON
96
+ for i, line in enumerate(new_lines):
97
+ if i > 0:
98
+ buffer += "\r\n"
99
+ buffer += line
100
+ buffer += SYNC_OUTPUT_OFF
101
+ _emit_log("first", buffer)
102
+ terminal.write(buffer)
103
+ state.cursor_row = len(new_lines) - 1
104
+ state.previous_lines = list(new_lines)
105
+ state.previous_width = width
106
+ return
107
+
108
+ # Width changed - full re-render
109
+ if width_changed:
110
+ buffer = SYNC_OUTPUT_ON
111
+ buffer += CLEAR_SCREEN_HOME
112
+ for i, line in enumerate(new_lines):
113
+ if i > 0:
114
+ buffer += "\r\n"
115
+ buffer += line
116
+ buffer += SYNC_OUTPUT_OFF
117
+ _emit_log("width_changed", buffer, old_width=state.previous_width)
118
+ terminal.write(buffer)
119
+ state.cursor_row = len(new_lines) - 1
120
+ state.previous_lines = list(new_lines)
121
+ state.previous_width = width
122
+ return
123
+
124
+ # Find first changed line
125
+ first_changed = -1
126
+ max_lines = max(len(new_lines), len(state.previous_lines))
127
+
128
+ for i in range(max_lines):
129
+ old_line = state.previous_lines[i] if i < len(state.previous_lines) else ""
130
+ new_line = new_lines[i] if i < len(new_lines) else ""
131
+ if old_line != new_line:
132
+ first_changed = i
133
+ break
134
+
135
+ # No changes
136
+ if first_changed == -1:
137
+ return
138
+
139
+ # Check if first_changed is outside the viewport
140
+ viewport_top = state.cursor_row - height + 1
141
+ if first_changed < viewport_top:
142
+ # First change is above viewport - need full re-render
143
+ buffer = SYNC_OUTPUT_ON
144
+ buffer += CLEAR_SCREEN_HOME
145
+ for i, line in enumerate(new_lines):
146
+ if i > 0:
147
+ buffer += "\r\n"
148
+ buffer += line
149
+ buffer += SYNC_OUTPUT_OFF
150
+ _emit_log(
151
+ "above_viewport", buffer,
152
+ first_changed=first_changed, viewport_top=viewport_top,
153
+ )
154
+ terminal.write(buffer)
155
+ state.cursor_row = len(new_lines) - 1
156
+ state.previous_lines = list(new_lines)
157
+ state.previous_width = width
158
+ return
159
+
160
+ # Render from first changed line to end
161
+ line_diff = first_changed - state.cursor_row
162
+
163
+ buffer = SYNC_OUTPUT_ON
164
+
165
+ # Move cursor to first changed line
166
+ if line_diff > 0:
167
+ buffer += f"\x1b[{line_diff}B" # Move down
168
+ elif line_diff < 0:
169
+ buffer += f"\x1b[{-line_diff}A" # Move up
170
+
171
+ buffer += "\r" # Move to column 0
172
+
173
+ # Render from first changed line to end, clearing each line before writing
174
+ cursor_after_render = first_changed
175
+ for i in range(first_changed, len(new_lines)):
176
+ if i > first_changed:
177
+ buffer += "\r\n"
178
+ cursor_after_render = i
179
+ buffer += CLEAR_LINE_FULL
180
+
181
+ line = new_lines[i]
182
+ if visible_width(line) > width:
183
+ line = truncate_to_width(line, width, ellipsis="\u2026")
184
+ buffer += line
185
+
186
+ # If we had more lines before, clear them
187
+ extra_cleared = 0
188
+ if len(state.previous_lines) > len(new_lines):
189
+ extra_cleared = len(state.previous_lines) - len(new_lines)
190
+ for _i in range(extra_cleared):
191
+ buffer += "\r\n" + CLEAR_LINE_FULL
192
+ # Move cursor back to correct position
193
+ lines_to_move_up = cursor_after_render + extra_cleared - (len(new_lines) - 1)
194
+ if lines_to_move_up > 0:
195
+ buffer += f"\x1b[{lines_to_move_up}A"
196
+
197
+ buffer += SYNC_OUTPUT_OFF
198
+
199
+ _emit_log(
200
+ "diff", buffer,
201
+ first_changed=first_changed,
202
+ cursor_move=line_diff,
203
+ redraw=len(new_lines) - first_changed,
204
+ extra_cleared=extra_cleared,
205
+ )
206
+
207
+ terminal.write(buffer)
208
+ state.cursor_row = len(new_lines) - 1
209
+ state.previous_lines = list(new_lines)
210
+ state.previous_width = width
@@ -0,0 +1,73 @@
1
+ """Standalone CLI spinner for long-running operations.
2
+
3
+ No TUI app needed — just stderr + ANSI escape codes.
4
+ Uses the same braille frames as the rollouts Loader component.
5
+
6
+ Usage:
7
+ with Spinner("Provisioning GPU...") as s:
8
+ provision()
9
+ s.update("Deploying code...")
10
+ deploy()
11
+ # prints: ✓ Deploying code...
12
+
13
+ # Or without context manager:
14
+ s = Spinner("Working...")
15
+ s.start()
16
+ s.update("Still working...")
17
+ s.stop() # prints: ✓ Still working...
18
+ s.stop("Failed!") # prints: ✗ Failed!
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import itertools
24
+ import sys
25
+ import threading
26
+ import time
27
+
28
+ BRAILLE_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
29
+
30
+
31
+ class Spinner:
32
+ """Braille spinner that overwrites a single stderr line."""
33
+
34
+ def __init__(self, message: str) -> None:
35
+ self._message = message
36
+ self._stop = threading.Event()
37
+ self._thread: threading.Thread | None = None
38
+
39
+ def update(self, message: str) -> None:
40
+ """Change the spinner text (thread-safe)."""
41
+ self._message = message
42
+
43
+ def start(self) -> None:
44
+ """Start the spinner background thread."""
45
+ assert self._thread is None, "Spinner already started"
46
+ self._thread = threading.Thread(target=self._spin, daemon=True)
47
+ self._thread.start()
48
+
49
+ def stop(self, final_message: str | None = None, success: bool = True) -> None:
50
+ """Stop the spinner and print final status."""
51
+ self._stop.set()
52
+ if self._thread is not None:
53
+ self._thread.join()
54
+ self._thread = None
55
+ msg = final_message or self._message
56
+ mark = "✓" if success else "✗"
57
+ sys.stderr.write(f"\r\033[K{mark} {msg}\n")
58
+ sys.stderr.flush()
59
+
60
+ def _spin(self) -> None:
61
+ for frame in itertools.cycle(BRAILLE_FRAMES):
62
+ if self._stop.is_set():
63
+ break
64
+ sys.stderr.write(f"\r\033[K{frame} {self._message}")
65
+ sys.stderr.flush()
66
+ time.sleep(0.08)
67
+
68
+ def __enter__(self) -> Spinner:
69
+ self.start()
70
+ return self
71
+
72
+ def __exit__(self, exc_type: type | None, *_: object) -> None:
73
+ self.stop(success=exc_type is None)