Skip to content

Commit 46cf411

Browse files
committed
feat(builder[callbacks]): add progress, before_script, script_output, and build_event callbacks
why: The builder needs to emit lifecycle events so a UI layer can render real-time progress without coupling builder logic to display code. what: - Add on_progress, on_before_script, on_script_output, on_build_event callback parameters to WorkspaceBuilder - Emit structured build events: session_created (with window_total, session_pane_total), window_started, pane_creating, window_done, workspace_built, before_script_started, before_script_done - Add on_line callback to run_before_script() for capturing script output - Add doctests for all callback types
1 parent e23569d commit 46cf411

File tree

2 files changed

+190
-4
lines changed

2 files changed

+190
-4
lines changed

src/tmuxp/util.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,12 @@
2828
def run_before_script(
2929
script_file: str | pathlib.Path,
3030
cwd: pathlib.Path | None = None,
31+
on_line: t.Callable[[str], None] | None = None,
3132
) -> int:
32-
"""Execute shell script, ``tee``-ing output to both terminal (if TTY) and buffer."""
33+
"""Execute shell script, streaming output to callback or terminal (if TTY).
34+
35+
Output is buffered and optionally forwarded via the ``on_line`` callback.
36+
"""
3337
script_cmd = shlex.split(str(script_file))
3438

3539
try:
@@ -68,13 +72,17 @@ def run_before_script(
6872

6973
if line_out and line_out.strip():
7074
out_buffer.append(line_out)
71-
if is_out_tty:
75+
if on_line is not None:
76+
on_line(line_out)
77+
elif is_out_tty:
7278
sys.stdout.write(line_out)
7379
sys.stdout.flush()
7480

7581
if line_err and line_err.strip():
7682
err_buffer.append(line_err)
77-
if is_err_tty:
83+
if on_line is not None:
84+
on_line(line_err)
85+
elif is_err_tty:
7886
sys.stderr.write(line_err)
7987
sys.stderr.flush()
8088

src/tmuxp/workspace/builder.py

Lines changed: 179 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,101 @@ class WorkspaceBuilder:
169169
>>> sorted([window.name for window in session.windows])
170170
['editor', 'logging', 'test']
171171
172+
**Progress callback:**
173+
174+
>>> calls: list[str] = []
175+
>>> progress_cfg = {
176+
... "session_name": "progress-demo",
177+
... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}],
178+
... }
179+
>>> builder = WorkspaceBuilder(
180+
... session_config=progress_cfg,
181+
... server=server,
182+
... on_progress=calls.append,
183+
... )
184+
>>> builder.build()
185+
>>> "Workspace built" in calls
186+
True
187+
188+
**Before-script hook:**
189+
190+
>>> hook_calls: list[bool] = []
191+
>>> no_script_cfg = {
192+
... "session_name": "hook-demo",
193+
... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}],
194+
... }
195+
>>> builder = WorkspaceBuilder(
196+
... session_config=no_script_cfg,
197+
... server=server,
198+
... on_before_script=lambda: hook_calls.append(True),
199+
... )
200+
>>> builder.build()
201+
>>> hook_calls # no before_script in config, callback not fired
202+
[]
203+
204+
**Script output hook:**
205+
206+
>>> script_lines: list[str] = []
207+
>>> no_script_cfg2 = {
208+
... "session_name": "script-output-demo",
209+
... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}],
210+
... }
211+
>>> builder = WorkspaceBuilder(
212+
... session_config=no_script_cfg2,
213+
... server=server,
214+
... on_script_output=script_lines.append,
215+
... )
216+
>>> builder.build()
217+
>>> script_lines # no before_script in config, callback not fired
218+
[]
219+
220+
**Build events hook:**
221+
222+
>>> events: list[dict] = []
223+
>>> event_cfg = {
224+
... "session_name": "events-demo",
225+
... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}],
226+
... }
227+
>>> builder = WorkspaceBuilder(
228+
... session_config=event_cfg,
229+
... server=server,
230+
... on_build_event=events.append,
231+
... )
232+
>>> builder.build()
233+
>>> [e["event"] for e in events]
234+
['session_created', 'window_started', 'pane_creating',
235+
'window_done', 'workspace_built']
236+
>>> next(e for e in events if e["event"] == "session_created")["session_pane_total"]
237+
1
238+
239+
**Build events with before_script:**
240+
241+
``before_script_started`` fires before the script runs;
242+
``before_script_done`` fires in ``finally`` (success or failure).
243+
244+
>>> script_events: list[dict] = []
245+
>>> script_event_cfg = {
246+
... "session_name": "script-events-demo",
247+
... "before_script": "echo hello",
248+
... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}],
249+
... }
250+
>>> builder = WorkspaceBuilder(
251+
... session_config=script_event_cfg,
252+
... server=server,
253+
... on_build_event=script_events.append,
254+
... )
255+
>>> builder.build()
256+
>>> event_names = [e["event"] for e in script_events]
257+
>>> "before_script_started" in event_names
258+
True
259+
>>> "before_script_done" in event_names
260+
True
261+
>>> bs_start = event_names.index("before_script_started")
262+
>>> bs_done = event_names.index("before_script_done")
263+
>>> win_start = event_names.index("window_started")
264+
>>> bs_start < bs_done < win_start
265+
True
266+
172267
The normal phase of loading is:
173268
174269
1. Load JSON / YAML file via :class:`pathlib.Path`::
@@ -211,12 +306,20 @@ class WorkspaceBuilder:
211306
server: Server
212307
_session: Session | None
213308
session_name: str
309+
on_progress: t.Callable[[str], None] | None
310+
on_before_script: t.Callable[[], None] | None
311+
on_script_output: t.Callable[[str], None] | None
312+
on_build_event: t.Callable[[dict[str, t.Any]], None] | None
214313

215314
def __init__(
216315
self,
217316
session_config: dict[str, t.Any],
218317
server: Server,
219318
plugins: list[t.Any] | None = None,
319+
on_progress: t.Callable[[str], None] | None = None,
320+
on_before_script: t.Callable[[], None] | None = None,
321+
on_script_output: t.Callable[[str], None] | None = None,
322+
on_build_event: t.Callable[[dict[str, t.Any]], None] | None = None,
220323
) -> None:
221324
"""Initialize workspace loading.
222325
@@ -231,6 +334,23 @@ def __init__(
231334
server : :class:`libtmux.Server`
232335
tmux server to build session in
233336
337+
on_progress : callable, optional
338+
callback for progress updates during building
339+
340+
on_before_script : callable, optional
341+
called just before ``before_script`` runs; use to clear the terminal
342+
(e.g. stop a spinner) so script output is not interleaved
343+
344+
on_script_output : callable, optional
345+
called with each output line from ``before_script`` subprocess; when
346+
set, raw TTY tee is suppressed so the caller can route lines to a
347+
live panel instead
348+
349+
on_build_event : callable, optional
350+
called with a dict event at each structural build milestone (session
351+
created, window started/done, pane creating, workspace built); used
352+
by the CLI to render a live session tree
353+
234354
Notes
235355
-----
236356
TODO: Initialize :class:`libtmux.Session` from here, in
@@ -248,6 +368,10 @@ def __init__(
248368

249369
self.session_config = session_config
250370
self.plugins = plugins
371+
self.on_progress = on_progress
372+
self.on_before_script = on_before_script
373+
self.on_script_output = on_script_output
374+
self.on_build_event = on_build_event
251375

252376
if self.server is not None and self.session_exists(
253377
session_name=self.session_config["session_name"],
@@ -332,7 +456,21 @@ def build(self, session: Session | None = None, append: bool = False) -> None:
332456
assert session is not None
333457
assert session.name is not None
334458

459+
if self.on_progress:
460+
self.on_progress(f"Session created: {session.name}")
461+
335462
self._session = session
463+
if self.on_build_event:
464+
self.on_build_event(
465+
{
466+
"event": "session_created",
467+
"name": session.name,
468+
"window_total": len(self.session_config["windows"]),
469+
"session_pane_total": sum(
470+
len(w.get("panes", [])) for w in self.session_config["windows"]
471+
),
472+
}
473+
)
336474
_log = TmuxpLoggerAdapter(
337475
logger,
338476
{"tmux_session": self.session_config["session_name"]},
@@ -354,6 +492,10 @@ def build(self, session: Session | None = None, append: bool = False) -> None:
354492
focus = None
355493

356494
if "before_script" in self.session_config:
495+
if self.on_before_script:
496+
self.on_before_script()
497+
if self.on_build_event:
498+
self.on_build_event({"event": "before_script_started"})
357499
try:
358500
cwd = None
359501

@@ -364,7 +506,11 @@ def build(self, session: Session | None = None, append: bool = False) -> None:
364506
_log.debug(
365507
"running before script",
366508
)
367-
run_before_script(self.session_config["before_script"], cwd=cwd)
509+
run_before_script(
510+
self.session_config["before_script"],
511+
cwd=cwd,
512+
on_line=self.on_script_output,
513+
)
368514
except Exception:
369515
_log.error(
370516
"before script failed",
@@ -376,6 +522,9 @@ def build(self, session: Session | None = None, append: bool = False) -> None:
376522
)
377523
self.session.kill()
378524
raise
525+
finally:
526+
if self.on_build_event:
527+
self.on_build_event({"event": "before_script_done"})
379528

380529
if "options" in self.session_config:
381530
for option, value in self.session_config["options"].items():
@@ -414,10 +563,17 @@ def build(self, session: Session | None = None, append: bool = False) -> None:
414563
if focus_pane:
415564
focus_pane.select()
416565

566+
if self.on_build_event:
567+
self.on_build_event({"event": "window_done"})
568+
417569
if focus:
418570
focus.select()
419571

572+
if self.on_progress:
573+
self.on_progress("Workspace built")
420574
_log.info("workspace built")
575+
if self.on_build_event:
576+
self.on_build_event({"event": "workspace_built"})
421577

422578
def iter_create_windows(
423579
self,
@@ -450,6 +606,17 @@ def iter_create_windows(
450606
):
451607
window_name = window_config.get("window_name", None)
452608

609+
if self.on_progress:
610+
self.on_progress(f"Creating window: {window_name or window_iterator}")
611+
if self.on_build_event:
612+
self.on_build_event(
613+
{
614+
"event": "window_started",
615+
"name": window_name or str(window_iterator),
616+
"pane_total": len(window_config["panes"]),
617+
}
618+
)
619+
453620
is_first_window_pass = self.first_window_pass(
454621
window_iterator,
455622
session,
@@ -545,6 +712,17 @@ def iter_create_panes(
545712
window_config["panes"],
546713
start=pane_base_index,
547714
):
715+
if self.on_progress:
716+
self.on_progress(f"Creating pane: {pane_index}")
717+
if self.on_build_event:
718+
self.on_build_event(
719+
{
720+
"event": "pane_creating",
721+
"pane_num": pane_index - int(pane_base_index) + 1,
722+
"pane_total": len(window_config["panes"]),
723+
}
724+
)
725+
548726
if pane_index == int(pane_base_index):
549727
pane = window.active_pane
550728
else:

0 commit comments

Comments
 (0)