labnotes 0.0.25__tar.gz → 0.0.27__tar.gz

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 (90) hide show
  1. {labnotes-0.0.25 → labnotes-0.0.27}/PKG-INFO +1 -1
  2. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/context.md +5 -2
  3. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/models.py +2 -0
  4. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/runner/describe_event.py +6 -0
  5. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/runner/run_experiment.py +67 -19
  6. labnotes-0.0.27/src/labnotes/tui/run_app.py +200 -0
  7. labnotes-0.0.25/src/labnotes/tui/run_app.py +0 -124
  8. {labnotes-0.0.25 → labnotes-0.0.27}/.gitignore +0 -0
  9. {labnotes-0.0.25 → labnotes-0.0.27}/CHANGELOG.md +0 -0
  10. {labnotes-0.0.25 → labnotes-0.0.27}/MIGRATIONS.md +0 -0
  11. {labnotes-0.0.25 → labnotes-0.0.27}/README.md +0 -0
  12. {labnotes-0.0.25 → labnotes-0.0.27}/pyproject.toml +0 -0
  13. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/__init__.py +0 -0
  14. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/asset_url.py +0 -0
  15. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/cell_progress.py +0 -0
  16. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/cli.py +0 -0
  17. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/context_guide.py +0 -0
  18. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/count_runs.py +0 -0
  19. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/create_app.py +0 -0
  20. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/create_experiment.py +0 -0
  21. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/data_html.py +0 -0
  22. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/default_config_text.py +0 -0
  23. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/default_counts.py +0 -0
  24. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/drain_watch.py +0 -0
  25. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/experiment_notes.py +0 -0
  26. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/experiment_progress.py +0 -0
  27. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/experiment_runs.py +0 -0
  28. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/experiments.py +0 -0
  29. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/format_value.py +0 -0
  30. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/init.py +0 -0
  31. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/list_experiments.py +0 -0
  32. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/list_runs.py +0 -0
  33. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/load_config.py +0 -0
  34. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/load_experiment.py +0 -0
  35. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/notebook_kernel.py +0 -0
  36. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/output_html.py +0 -0
  37. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/parse_run_view.py +0 -0
  38. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/pivot_cells.py +0 -0
  39. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/print_rows.py +0 -0
  40. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/python_blocks.py +0 -0
  41. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/read_config.py +0 -0
  42. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/read_path.py +0 -0
  43. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/render_markdown.py +0 -0
  44. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/runner/__init__.py +0 -0
  45. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/runner/execute_run.py +0 -0
  46. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/runner/lock_path.py +0 -0
  47. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/runner/new_run_id.py +0 -0
  48. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/runner/param_args.py +0 -0
  49. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/runner/param_grid.py +0 -0
  50. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/runner/plan_runs.py +0 -0
  51. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/runner/read_experiment_config.py +0 -0
  52. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/runner/run_folder.py +0 -0
  53. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/runner/run_headless.py +0 -0
  54. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/runner/shell_command.py +0 -0
  55. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/runner/start_experiment.py +0 -0
  56. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/runner/stop_then_kill.py +0 -0
  57. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/runner/wait_for_group.py +0 -0
  58. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/runner/write_run_json.py +0 -0
  59. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/saved_outputs.py +0 -0
  60. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/serve_app.py +0 -0
  61. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/server.py +0 -0
  62. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/sort_href.py +0 -0
  63. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/sql_quote.py +0 -0
  64. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/static/labnotes.css +0 -0
  65. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/static/row-link.js +0 -0
  66. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/static/run-cell.js +0 -0
  67. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/static/view-toggle.js +0 -0
  68. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/stop_idle_kernels.py +0 -0
  69. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/summarize_config.py +0 -0
  70. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/tables/__init__.py +0 -0
  71. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/tables/experiment_rows.py +0 -0
  72. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/tables/file_prefix.py +0 -0
  73. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/tables/iso_time.py +0 -0
  74. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/tables/list_experiment_runs.py +0 -0
  75. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/tables/open_tables.py +0 -0
  76. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/tables/path_glob.py +0 -0
  77. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/tables/path_pattern.py +0 -0
  78. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/tables/read_experiment.py +0 -0
  79. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/tables/read_file.py +0 -0
  80. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/tables/read_run.py +0 -0
  81. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/tables/summarize_experiments.py +0 -0
  82. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/templates/base.html +0 -0
  83. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/templates/experiment.html +0 -0
  84. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/templates/index.html +0 -0
  85. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/templates/macros.html +0 -0
  86. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/tui/__init__.py +0 -0
  87. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/tui/dim.py +0 -0
  88. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/tui/format_elapsed.py +0 -0
  89. {labnotes-0.0.25 → labnotes-0.0.27}/src/labnotes/tui/run_tui.py +0 -0
  90. {labnotes-0.0.25 → labnotes-0.0.27}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.25
3
+ Version: 0.0.27
4
4
  Summary: Browse experiment designs, notes, and run data from a folder.
5
5
  Project-URL: Repository, https://github.com/thekevinscott/labnotes
6
6
  Author-email: Kevin Scott <me@thekevinscott.com>
@@ -88,10 +88,13 @@ Runs can take a long time. Start `run` in the background, then check on it with
88
88
  In a terminal, `run` shows a live table of runs (status and time) and stays
89
89
  open when they finish. Runs not started yet show grayed as `pending`, and as
90
90
  `skipped` once stopped. Move the cursor to a pending run and press `n` to run it
91
- next; `q` quits. Elsewhere, or with `--no-tui`, it prints a
91
+ next. `d` deselects a pending run so it won't run; `d` again puts it back at
92
+ the end of the queue. `/` filters rows by params text; Enter keeps the filter,
93
+ Escape clears it. `+` and `-` change how many run at once; lowering it lets running runs
94
+ finish; `q` quits. Elsewhere, or with `--no-tui`, it prints a
92
95
  line per event instead.
93
96
 
94
- - `--concurrency N` runs N at a time (default 1).
97
+ - `--concurrency N` runs N at a time to start with (default 1).
95
98
  - `-e PATH` reads another file instead of `experiment.toml`.
96
99
  - Ctrl-C stops the running scripts and records their end. A second Ctrl-C
97
100
  kills them. Closing the terminal or sending SIGTERM stops them the same way,
@@ -12,6 +12,8 @@ Report = Callable[[dict[str, object]], None]
12
12
  class Controls:
13
13
  interrupt: Callable[[], None]
14
14
  run_next: Callable[[str], None]
15
+ set_concurrency: Callable[[int], None]
16
+ toggle: Callable[[str], None]
15
17
 
16
18
 
17
19
  Listen = Callable[[Controls], None]
@@ -14,6 +14,12 @@ def describe_event(event: dict[str, object]) -> str:
14
14
  return f"{event['run']} {outcome}"
15
15
  case "queued":
16
16
  return f"{event['run']} runs next."
17
+ case "deselected":
18
+ return f"{event['run']} deselected."
19
+ case "reselected":
20
+ return f"{event['run']} back in the queue."
21
+ case "concurrency":
22
+ return f"{event['concurrency']} at a time."
17
23
  case "stopping":
18
24
  return "Stopping runs. Ctrl-C again to kill them."
19
25
  case _:
@@ -19,6 +19,8 @@ from labnotes.runner.run_folder import run_folder
19
19
  from labnotes.tables.list_experiment_runs import list_experiment_runs
20
20
  from labnotes.tables.open_tables import open_tables
21
21
 
22
+ Entry = tuple[str, dict[str, object]]
23
+
22
24
 
23
25
  async def run_experiment(
24
26
  config: LabnotesConfig,
@@ -37,7 +39,7 @@ async def run_experiment(
37
39
  run in planned or run_folder(config.root, config.runs, name, run).exists()
38
40
  )
39
41
 
40
- plan: list[tuple[str, dict[str, object]]] = []
42
+ plan: list[Entry] = []
41
43
  for params in plan_runs(
42
44
  param_grid(experiment.params), experiment.repeats, finished
43
45
  ):
@@ -65,26 +67,72 @@ async def run_experiment(
65
67
  report({"event": "killing"})
66
68
  signal_runs(signal.SIGKILL)
67
69
 
70
+ held: list[Entry] = []
71
+
72
+ def find(entries: list[Entry], run: str) -> Entry | None:
73
+ return next((entry for entry in entries if entry[0] == run), None)
74
+
75
+ def arrange(event: str, run: str) -> None:
76
+ pending = [id_ for id_, _ in plan]
77
+ deselected = [id_ for id_, _ in held]
78
+ report({"event": event, "run": run, "pending": pending, "held": deselected})
79
+
68
80
  def run_next(run: str) -> None:
69
- entry = next((entry for entry in plan if entry[0] == run), None)
81
+ entry = find(plan, run)
70
82
  if entry is not None:
71
83
  plan.remove(entry)
72
84
  plan.insert(0, entry)
73
- pending = [id_ for id_, _ in plan]
74
- report({"event": "queued", "run": run, "pending": pending})
75
-
76
- async def work() -> None:
77
- while plan:
78
- run, params = plan.pop(0)
79
- folder = run_folder(config.root, config.runs, name, run)
80
- folder.mkdir(parents=True)
81
- report({"event": "started", "run": run, "params": params})
82
- result = await execute_run(
83
- config.root, folder, experiment.command, params, running, stopping
84
- )
85
- report({"event": "ended", "run": run, **result})
86
- results.append(result)
87
-
88
- listen(Controls(interrupt, run_next))
89
- await asyncio.gather(*(work() for _ in range(concurrency)))
85
+ arrange("queued", run)
86
+
87
+ def toggle(run: str) -> None:
88
+ if stopping.is_set():
89
+ return
90
+ if (entry := find(plan, run)) is not None:
91
+ plan.remove(entry)
92
+ held.append(entry)
93
+ arrange("deselected", run)
94
+ elif (entry := find(held, run)) is not None:
95
+ held.remove(entry)
96
+ plan.append(entry)
97
+ arrange("reselected", run)
98
+ fill()
99
+
100
+ limit = concurrency
101
+ live: set[asyncio.Task[None]] = set()
102
+ started: list[asyncio.Task[None]] = []
103
+ idle = asyncio.Event()
104
+
105
+ def fill() -> None:
106
+ while plan and len(live) < limit:
107
+ task = asyncio.create_task(start(*plan.pop(0)))
108
+ task.add_done_callback(end)
109
+ live.add(task)
110
+ started.append(task)
111
+ if not live:
112
+ idle.set()
113
+
114
+ def end(task: asyncio.Task[None]) -> None:
115
+ live.remove(task)
116
+ fill()
117
+
118
+ def set_concurrency(count: int) -> None:
119
+ nonlocal limit
120
+ limit = max(1, count)
121
+ report({"event": "concurrency", "concurrency": limit})
122
+ fill()
123
+
124
+ async def start(run: str, params: dict[str, object]) -> None:
125
+ folder = run_folder(config.root, config.runs, name, run)
126
+ folder.mkdir(parents=True)
127
+ report({"event": "started", "run": run, "params": params})
128
+ result = await execute_run(
129
+ config.root, folder, experiment.command, params, running, stopping
130
+ )
131
+ report({"event": "ended", "run": run, **result})
132
+ results.append(result)
133
+
134
+ listen(Controls(interrupt, run_next, set_concurrency, toggle))
135
+ fill()
136
+ await idle.wait()
137
+ await asyncio.gather(*started)
90
138
  return results
@@ -0,0 +1,200 @@
1
+ import signal
2
+ import time
3
+
4
+ from textual.app import App, ComposeResult
5
+ from textual.binding import Binding
6
+ from textual.widgets import DataTable, Footer, Header, Input, Static
7
+
8
+ from labnotes.models import Controls, Start
9
+ from labnotes.runner.describe_event import describe_event
10
+ from labnotes.runner.stop_then_kill import stop_then_kill
11
+ from labnotes.runner.param_args import param_args
12
+ from labnotes.tui.dim import dim
13
+ from labnotes.tui.format_elapsed import format_elapsed
14
+
15
+ COLUMNS = ("run", "params", "status", "time")
16
+
17
+
18
+ class RunApp(App[None]):
19
+ BINDINGS = [
20
+ Binding("ctrl+c", "interrupt", "Stop runs"),
21
+ Binding("n", "run_next", "Run next"),
22
+ Binding("d", "toggle_run", "Deselect"),
23
+ Binding("slash", "filter", "Filter", key_display="/"),
24
+ Binding("escape", "clear_filter", "Clear filter"),
25
+ Binding("plus,equals_sign", "concurrency(1)", "More at once", key_display="+"),
26
+ Binding("minus", "concurrency(-1)", "Fewer at once", key_display="-"),
27
+ Binding("q", "quit", "Quit"),
28
+ ]
29
+
30
+ def __init__(self, start: Start, title: str) -> None:
31
+ super().__init__()
32
+ self.start = start
33
+ self.title = title
34
+ self.results: list[dict[str, object]] = []
35
+ self.controls = Controls(
36
+ lambda: None, lambda run: None, lambda count: None, lambda run: None
37
+ )
38
+ self.concurrency = 1
39
+ self.done = False
40
+ self.started: dict[str, float] = {}
41
+ self.pending: set[str] = set()
42
+ self.cells: dict[str, list[object]] = {}
43
+ self.filter = ""
44
+
45
+ def compose(self) -> ComposeResult:
46
+ yield Header()
47
+ yield DataTable(id="runs", cursor_type="row")
48
+ yield Input(id="filter", placeholder="Filter params")
49
+ yield Static(id="status")
50
+ yield Footer()
51
+
52
+ def on_mount(self) -> None:
53
+ table = self.query_one(DataTable)
54
+ for column in COLUMNS:
55
+ table.add_column(column, key=column)
56
+ self.query_one(Input).display = False
57
+
58
+ def on_ready(self) -> None:
59
+ self.set_interval(1, self.tick)
60
+ self.run_worker(self.drive())
61
+
62
+ async def drive(self) -> None:
63
+ self.results = await self.start(self.show_event, self.listen)
64
+ self.done = True
65
+ failed = sum(result["exit_code"] != 0 for result in self.results)
66
+ passed = len(self.results) - failed
67
+ self.show(f"{passed} passed, {failed} failed. q to quit.")
68
+
69
+ def listen(self, controls: Controls) -> None:
70
+ self.controls = controls
71
+ stop_then_kill(self.action_interrupt, signal.SIGTERM, signal.SIGHUP)
72
+
73
+ def show_event(self, event: dict[str, object]) -> None:
74
+ table = self.query_one(DataTable)
75
+ run = str(event.get("run"))
76
+ match event["event"]:
77
+ case "planned":
78
+ for planned in event["runs"]: # type: ignore[attr-defined]
79
+ args = " ".join(param_args(planned["params"]))
80
+ cells = (planned["run"], args, "pending", "")
81
+ self.cells[planned["run"]] = [*map(dim, cells)]
82
+ self.pending.add(planned["run"])
83
+ self.redraw(table.cursor_row)
84
+ self.show_concurrency(event)
85
+ self.show(describe_event(event))
86
+ case "started":
87
+ self.pending.discard(run)
88
+ self.started[run] = time.monotonic()
89
+ args = " ".join(param_args(event["params"])) # type: ignore[arg-type]
90
+ cells = (run, args, "running", format_elapsed(0))
91
+ for column, cell in zip(COLUMNS, cells):
92
+ self.set_cell(run, column, cell)
93
+ case "ended":
94
+ outcome = event["error"] or f"exited {event['exit_code']}"
95
+ self.set_cell(run, "status", outcome)
96
+ self.tick()
97
+ del self.started[run]
98
+ case "queued":
99
+ self.reorder(event, table.cursor_row)
100
+ table.move_cursor(row=table.get_row_index(run))
101
+ self.show(describe_event(event))
102
+ case "deselected":
103
+ self.pending.discard(run)
104
+ self.set_cell(run, "status", dim("deselected"))
105
+ self.reorder(event, table.cursor_row)
106
+ self.show(describe_event(event))
107
+ case "reselected":
108
+ self.pending.add(run)
109
+ self.set_cell(run, "status", dim("pending"))
110
+ self.reorder(event, table.cursor_row)
111
+ self.show(describe_event(event))
112
+ case "concurrency":
113
+ self.show_concurrency(event)
114
+ self.show(describe_event(event))
115
+ case "stopping":
116
+ for pending in self.pending:
117
+ self.set_cell(pending, "status", dim("skipped"))
118
+ self.show(describe_event(event))
119
+ case _:
120
+ self.show(describe_event(event))
121
+
122
+ def reorder(self, event: dict[str, object], row: int) -> None:
123
+ waiting: list[str] = [*event["pending"], *event["held"]] # type: ignore[misc]
124
+ for run in waiting:
125
+ self.cells[run] = self.cells.pop(run)
126
+ self.redraw(row)
127
+
128
+ def redraw(self, row: int) -> None:
129
+ table = self.query_one(DataTable)
130
+ table.clear()
131
+ for run, cells in self.cells.items():
132
+ if self.filter in str(cells[1]):
133
+ table.add_row(*cells, key=run)
134
+ table.move_cursor(row=row)
135
+
136
+ def set_cell(self, run: str, column: str, cell: object) -> None:
137
+ self.cells[run][COLUMNS.index(column)] = cell
138
+ table = self.query_one(DataTable)
139
+ if run in table.rows:
140
+ table.update_cell(run, column, cell, update_width=True)
141
+
142
+ def show_concurrency(self, event: dict[str, object]) -> None:
143
+ self.concurrency = int(event["concurrency"]) # type: ignore[call-overload]
144
+ self.sub_title = f"{self.concurrency} at a time"
145
+
146
+ def tick(self) -> None:
147
+ for run, began in self.started.items():
148
+ self.set_cell(run, "time", format_elapsed(time.monotonic() - began))
149
+
150
+ def show(self, text: str) -> None:
151
+ self.query_one("#status", Static).update(text)
152
+
153
+ def action_interrupt(self) -> None:
154
+ if self.done:
155
+ self.exit()
156
+ else:
157
+ self.controls.interrupt()
158
+
159
+ async def action_quit(self) -> None:
160
+ if self.done:
161
+ self.exit()
162
+ else:
163
+ self.show("Runs are still going. Ctrl-C stops them.")
164
+
165
+ def cursor_run(self) -> str | None:
166
+ table = self.query_one(DataTable)
167
+ if not table.row_count:
168
+ return None
169
+ cell = table.coordinate_to_cell_key(table.cursor_coordinate)
170
+ return str(cell.row_key.value)
171
+
172
+ def action_run_next(self) -> None:
173
+ if (run := self.cursor_run()) is not None:
174
+ self.controls.run_next(run)
175
+
176
+ def action_toggle_run(self) -> None:
177
+ if (run := self.cursor_run()) is not None:
178
+ self.controls.toggle(run)
179
+
180
+ def action_concurrency(self, change: int) -> None:
181
+ self.controls.set_concurrency(self.concurrency + change)
182
+
183
+ def action_filter(self) -> None:
184
+ field = self.query_one(Input)
185
+ field.display = True
186
+ field.focus()
187
+
188
+ def action_clear_filter(self) -> None:
189
+ field = self.query_one(Input)
190
+ field.value = ""
191
+ field.display = False
192
+ self.query_one(DataTable).focus()
193
+
194
+ def on_input_changed(self, event: Input.Changed) -> None:
195
+ self.filter = event.value
196
+ self.redraw(self.query_one(DataTable).cursor_row)
197
+
198
+ def on_input_submitted(self, event: Input.Submitted) -> None:
199
+ event.input.display = bool(event.value)
200
+ self.query_one(DataTable).focus()
@@ -1,124 +0,0 @@
1
- import signal
2
- import time
3
-
4
- from textual.app import App, ComposeResult
5
- from textual.binding import Binding
6
- from textual.widgets import DataTable, Footer, Header, Static
7
-
8
- from labnotes.models import Controls, Start
9
- from labnotes.runner.describe_event import describe_event
10
- from labnotes.runner.stop_then_kill import stop_then_kill
11
- from labnotes.runner.param_args import param_args
12
- from labnotes.tui.dim import dim
13
- from labnotes.tui.format_elapsed import format_elapsed
14
-
15
- COLUMNS = ("run", "params", "status", "time")
16
-
17
-
18
- class RunApp(App[None]):
19
- BINDINGS = [
20
- Binding("ctrl+c", "interrupt", "Stop runs"),
21
- Binding("n", "run_next", "Run next"),
22
- Binding("q", "quit", "Quit"),
23
- ]
24
-
25
- def __init__(self, start: Start, title: str) -> None:
26
- super().__init__()
27
- self.start = start
28
- self.title = title
29
- self.results: list[dict[str, object]] = []
30
- self.controls = Controls(lambda: None, lambda run: None)
31
- self.done = False
32
- self.started: dict[str, float] = {}
33
- self.pending: set[str] = set()
34
-
35
- def compose(self) -> ComposeResult:
36
- yield Header()
37
- yield DataTable(id="runs", cursor_type="row")
38
- yield Static(id="status")
39
- yield Footer()
40
-
41
- def on_mount(self) -> None:
42
- table = self.query_one(DataTable)
43
- for column in COLUMNS:
44
- table.add_column(column, key=column)
45
-
46
- def on_ready(self) -> None:
47
- self.set_interval(1, self.tick)
48
- self.run_worker(self.drive())
49
-
50
- async def drive(self) -> None:
51
- self.results = await self.start(self.show_event, self.listen)
52
- self.done = True
53
- failed = sum(result["exit_code"] != 0 for result in self.results)
54
- passed = len(self.results) - failed
55
- self.show(f"{passed} passed, {failed} failed. q to quit.")
56
-
57
- def listen(self, controls: Controls) -> None:
58
- self.controls = controls
59
- stop_then_kill(self.action_interrupt, signal.SIGTERM, signal.SIGHUP)
60
-
61
- def show_event(self, event: dict[str, object]) -> None:
62
- table = self.query_one(DataTable)
63
- run = str(event.get("run"))
64
- match event["event"]:
65
- case "planned":
66
- for planned in event["runs"]: # type: ignore[attr-defined]
67
- args = " ".join(param_args(planned["params"]))
68
- cells = (planned["run"], args, "pending", "")
69
- table.add_row(*map(dim, cells), key=planned["run"])
70
- self.pending.add(planned["run"])
71
- self.show(describe_event(event))
72
- case "started":
73
- self.pending.discard(run)
74
- self.started[run] = time.monotonic()
75
- args = " ".join(param_args(event["params"])) # type: ignore[arg-type]
76
- cells = (run, args, "running", format_elapsed(0))
77
- for column, cell in zip(COLUMNS, cells):
78
- table.update_cell(run, column, cell, update_width=True)
79
- case "ended":
80
- outcome = event["error"] or f"exited {event['exit_code']}"
81
- table.update_cell(run, "status", outcome, update_width=True)
82
- self.tick()
83
- del self.started[run]
84
- case "queued":
85
- for pending in event["pending"]: # type: ignore[attr-defined]
86
- row = table.get_row(pending)
87
- table.remove_row(pending)
88
- table.add_row(*row, key=pending)
89
- table.move_cursor(row=table.get_row_index(run))
90
- self.show(describe_event(event))
91
- case "stopping":
92
- for pending in self.pending:
93
- table.update_cell(pending, "status", dim("skipped"))
94
- self.show(describe_event(event))
95
- case _:
96
- self.show(describe_event(event))
97
-
98
- def tick(self) -> None:
99
- table = self.query_one(DataTable)
100
- for run, began in self.started.items():
101
- table.update_cell(
102
- run, "time", format_elapsed(time.monotonic() - began), update_width=True
103
- )
104
-
105
- def show(self, text: str) -> None:
106
- self.query_one("#status", Static).update(text)
107
-
108
- def action_interrupt(self) -> None:
109
- if self.done:
110
- self.exit()
111
- else:
112
- self.controls.interrupt()
113
-
114
- async def action_quit(self) -> None:
115
- if self.done:
116
- self.exit()
117
- else:
118
- self.show("Runs are still going. Ctrl-C stops them.")
119
-
120
- def action_run_next(self) -> None:
121
- table = self.query_one(DataTable)
122
- if table.row_count:
123
- cell = table.coordinate_to_cell_key(table.cursor_coordinate)
124
- self.controls.run_next(str(cell.row_key.value))
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes