labnotes 0.0.35__tar.gz → 0.0.37__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 (93) hide show
  1. {labnotes-0.0.35 → labnotes-0.0.37}/PKG-INFO +1 -1
  2. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/context.md +4 -1
  3. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/runner/run_experiment.py +9 -2
  4. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/tui/run_app.py +64 -6
  5. {labnotes-0.0.35 → labnotes-0.0.37}/.gitignore +0 -0
  6. {labnotes-0.0.35 → labnotes-0.0.37}/CHANGELOG.md +0 -0
  7. {labnotes-0.0.35 → labnotes-0.0.37}/MIGRATIONS.md +0 -0
  8. {labnotes-0.0.35 → labnotes-0.0.37}/README.md +0 -0
  9. {labnotes-0.0.35 → labnotes-0.0.37}/pyproject.toml +0 -0
  10. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/__init__.py +0 -0
  11. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/asset_url.py +0 -0
  12. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/cell_progress.py +0 -0
  13. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/check_counts.py +0 -0
  14. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/cli.py +0 -0
  15. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/context_guide.py +0 -0
  16. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/count_runs.py +0 -0
  17. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/create_app.py +0 -0
  18. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/create_experiment.py +0 -0
  19. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/data_html.py +0 -0
  20. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/default_config_text.py +0 -0
  21. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/default_counts.py +0 -0
  22. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/drain_watch.py +0 -0
  23. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/experiment_notes.py +0 -0
  24. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/experiment_progress.py +0 -0
  25. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/experiment_runs.py +0 -0
  26. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/experiments.py +0 -0
  27. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/format_value.py +0 -0
  28. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/init.py +0 -0
  29. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/list_experiments.py +0 -0
  30. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/list_runs.py +0 -0
  31. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/load_config.py +0 -0
  32. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/load_experiment.py +0 -0
  33. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/models.py +0 -0
  34. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/notebook_kernel.py +0 -0
  35. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/output_html.py +0 -0
  36. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/parse_run_view.py +0 -0
  37. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/pivot_cells.py +0 -0
  38. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/print_rows.py +0 -0
  39. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/python_blocks.py +0 -0
  40. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/read_config.py +0 -0
  41. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/read_path.py +0 -0
  42. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/render_markdown.py +0 -0
  43. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/runner/__init__.py +0 -0
  44. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/runner/describe_event.py +0 -0
  45. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/runner/execute_run.py +0 -0
  46. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/runner/hold_folder.py +0 -0
  47. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/runner/new_invocation_id.py +0 -0
  48. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/runner/new_run_id.py +0 -0
  49. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/runner/param_args.py +0 -0
  50. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/runner/param_grid.py +0 -0
  51. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/runner/plan_runs.py +0 -0
  52. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/runner/read_experiment_config.py +0 -0
  53. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/runner/run_folder.py +0 -0
  54. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/runner/run_headless.py +0 -0
  55. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/runner/runs_parent.py +0 -0
  56. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/runner/shell_command.py +0 -0
  57. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/runner/start_experiment.py +0 -0
  58. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/runner/stop_then_kill.py +0 -0
  59. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/runner/wait_for_group.py +0 -0
  60. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/runner/write_run_json.py +0 -0
  61. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/saved_outputs.py +0 -0
  62. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/serve_app.py +0 -0
  63. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/server.py +0 -0
  64. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/sort_href.py +0 -0
  65. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/sql_quote.py +0 -0
  66. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/static/labnotes.css +0 -0
  67. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/static/row-link.js +0 -0
  68. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/static/run-cell.js +0 -0
  69. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/static/view-toggle.js +0 -0
  70. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/stop_idle_kernels.py +0 -0
  71. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/summarize_config.py +0 -0
  72. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/tables/__init__.py +0 -0
  73. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/tables/experiment_rows.py +0 -0
  74. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/tables/file_prefix.py +0 -0
  75. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/tables/iso_time.py +0 -0
  76. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/tables/list_experiment_runs.py +0 -0
  77. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/tables/open_tables.py +0 -0
  78. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/tables/path_glob.py +0 -0
  79. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/tables/path_pattern.py +0 -0
  80. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/tables/read_experiment.py +0 -0
  81. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/tables/read_file.py +0 -0
  82. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/tables/read_run.py +0 -0
  83. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/tables/summarize_experiments.py +0 -0
  84. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/templates/base.html +0 -0
  85. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/templates/experiment.html +0 -0
  86. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/templates/index.html +0 -0
  87. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/templates/macros.html +0 -0
  88. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/tui/__init__.py +0 -0
  89. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/tui/dim.py +0 -0
  90. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/tui/format_elapsed.py +0 -0
  91. {labnotes-0.0.35 → labnotes-0.0.37}/src/labnotes/tui/run_tui.py +0 -0
  92. {labnotes-0.0.35 → labnotes-0.0.37}/testing-conventions.toml +0 -0
  93. {labnotes-0.0.35 → labnotes-0.0.37}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.35
3
+ Version: 0.0.37
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>
@@ -86,6 +86,7 @@ A script run by labnotes should:
86
86
  - take its output folder as an argument, passed in `command` as `{run_dir}`, and write everything it produces there;
87
87
  - exit non-zero when it fails, so the run counts as failed and runs again next time;
88
88
  - stop any background process it starts before it exits; the run ends when `command` exits.
89
+ - flush its output as it goes (`print(..., flush=True)`, or `PYTHONUNBUFFERED=1`), so the live view shows it as it happens.
89
90
 
90
91
  Runs can take a long time. Start `run` in the background, then check on it with `labnotes experiments foo runs`; a run with no `ended` is still going.
91
92
 
@@ -98,7 +99,9 @@ the end of the queue. `/` filters rows by params text; Enter keeps the filter,
98
99
  Escape clears it. `D` deselects every pending run the filter shows. `k` stops the
99
100
  running run under the cursor; `k` again kills it. `+` and
100
101
  `-` change how many run at once; lowering it lets running runs
101
- finish; `q` quits. Elsewhere, or with `--no-tui`, it prints a
102
+ finish. Enter on a run follows its `stdout.log`; `e` switches to
103
+ `stderr.log` and back, and Escape returns to the table. An `adhoc` experiment that
104
+ plans a single run opens straight on its output. `q` quits. Elsewhere, or with `--no-tui`, it prints a
102
105
  line per event instead.
103
106
 
104
107
  - `--concurrency N` runs N at a time to start with (default 1).
@@ -53,7 +53,14 @@ async def run_experiment(
53
53
  planned.add(run)
54
54
  plan.append((run, params))
55
55
  runs = [{"run": run, "params": params} for run, params in plan]
56
- report({"event": "planned", "runs": runs, "concurrency": concurrency})
56
+ report(
57
+ {
58
+ "event": "planned",
59
+ "runs": runs,
60
+ "concurrency": concurrency,
61
+ "mode": experiment.mode,
62
+ }
63
+ )
57
64
  running: dict[str, set[asyncio.subprocess.Process]] = {}
58
65
  stopped: set[str] = set()
59
66
  results: list[dict[str, object]] = []
@@ -166,7 +173,7 @@ async def run_experiment(
166
173
  async def start(run: str, params: dict[str, object]) -> None:
167
174
  folder = run_folder(config.root, config.runs, name, run)
168
175
  folder.mkdir(parents=True)
169
- report({"event": "started", "run": run, "params": params})
176
+ report({"event": "started", "run": run, "params": params, "folder": folder})
170
177
  running[run] = set()
171
178
  result = await execute_run(
172
179
  config.root,
@@ -1,10 +1,12 @@
1
+ import codecs
1
2
  import signal
2
3
  import time
4
+ from pathlib import Path
3
5
 
4
6
  from rich.text import Text
5
7
  from textual.app import App, ComposeResult
6
8
  from textual.binding import Binding
7
- from textual.widgets import DataTable, Footer, Header, Input, Static
9
+ from textual.widgets import DataTable, Footer, Header, Input, Log, Static
8
10
 
9
11
  from labnotes.models import Controls, Start
10
12
  from labnotes.runner.describe_event import describe_event
@@ -14,6 +16,7 @@ from labnotes.tui.dim import dim
14
16
  from labnotes.tui.format_elapsed import format_elapsed
15
17
 
16
18
  COLUMNS = ("run", "params", "status", "time")
19
+ OTHER_STREAM = {"stdout": "stderr", "stderr": "stdout"}
17
20
 
18
21
 
19
22
  class RunApp(App[None]):
@@ -26,7 +29,8 @@ class RunApp(App[None]):
26
29
  Binding("h", "hide_failed", "Hide failed"),
27
30
  Binding("r", "retry", "Retry"),
28
31
  Binding("slash", "filter", "Filter", key_display="/"),
29
- Binding("escape", "clear_filter", "Clear filter"),
32
+ Binding("e", "switch_output", "stdout/stderr"),
33
+ Binding("escape", "leave", "Back"),
30
34
  Binding("plus,equals_sign", "concurrency(1)", "More at once", key_display="+"),
31
35
  Binding("minus", "concurrency(-1)", "Fewer at once", key_display="-"),
32
36
  Binding("q", "quit", "Quit"),
@@ -56,10 +60,16 @@ class RunApp(App[None]):
56
60
  self.filter = ""
57
61
  self.failed: set[str] = set()
58
62
  self.hiding = False
63
+ self.folders: dict[str, Path] = {}
64
+ self.viewing: str | None = None
65
+ self.stream = "stdout"
66
+ self.offset: int
67
+ self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
59
68
 
60
69
  def compose(self) -> ComposeResult:
61
70
  yield Header()
62
71
  yield DataTable(id="runs", cursor_type="row")
72
+ yield Log(id="output")
63
73
  yield Input(id="filter", placeholder="Filter params")
64
74
  yield Static(id="status")
65
75
  yield Footer()
@@ -69,9 +79,11 @@ class RunApp(App[None]):
69
79
  for column in COLUMNS:
70
80
  table.add_column(column, key=column)
71
81
  self.query_one(Input).display = False
82
+ self.query_one(Log).display = False
72
83
 
73
84
  def on_ready(self) -> None:
74
85
  self.set_interval(1, self.tick)
86
+ self.set_interval(0.25, self.follow)
75
87
  self.run_worker(self.drive())
76
88
 
77
89
  async def drive(self) -> None:
@@ -95,10 +107,14 @@ class RunApp(App[None]):
95
107
  self.redraw(table.cursor_row)
96
108
  self.show_concurrency(event)
97
109
  self.show(describe_event(event))
110
+ match event["mode"], event["runs"]:
111
+ case "adhoc", [only]:
112
+ self.view(only["run"], "stdout") # type: ignore[index]
98
113
  case "started":
99
114
  self.idle = False
100
115
  self.pending.discard(run)
101
116
  self.started[run] = time.monotonic()
117
+ self.folders[run] = Path(str(event["folder"]))
102
118
  if run == self.next_run:
103
119
  self.show("")
104
120
  args = " ".join(param_args(event["params"])) # type: ignore[arg-type]
@@ -139,9 +155,7 @@ class RunApp(App[None]):
139
155
  self.show(describe_event(event))
140
156
  case "idle":
141
157
  self.idle = True
142
- failed = len(self.failed)
143
- passed = len(self.ended) - failed
144
- self.show(f"{passed} passed, {failed} failed. q to quit.")
158
+ self.show_summary()
145
159
  case "concurrency":
146
160
  self.show_concurrency(event)
147
161
  self.show(describe_event(event))
@@ -152,6 +166,11 @@ class RunApp(App[None]):
152
166
  case _:
153
167
  self.show(describe_event(event))
154
168
 
169
+ def show_summary(self) -> None:
170
+ failed = len(self.failed)
171
+ passed = len(self.ended) - failed
172
+ self.show(f"{passed} passed, {failed} failed. q to quit.")
173
+
155
174
  def reorder(self, event: dict[str, object], row: int) -> None:
156
175
  waiting: list[str] = [*event["pending"], *event["held"]] # type: ignore[misc]
157
176
  for run in waiting:
@@ -239,7 +258,46 @@ class RunApp(App[None]):
239
258
  field.display = True
240
259
  field.focus()
241
260
 
242
- def action_clear_filter(self) -> None:
261
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
262
+ self.view(str(event.row_key.value), "stdout")
263
+
264
+ def view(self, run: str, stream: str) -> None:
265
+ self.viewing = run
266
+ self.stream = stream
267
+ self.offset = 0
268
+ self.decoder.reset()
269
+ self.query_one(Log).clear()
270
+ self.query_one(DataTable).display = False
271
+ self.query_one(Log).display = True
272
+ self.show(f"{run} {stream}. e switches, Escape goes back.")
273
+ self.follow()
274
+
275
+ def follow(self) -> None:
276
+ if self.viewing not in self.folders:
277
+ return
278
+ path = self.folders[self.viewing] / f"{self.stream}.log"
279
+ if not path.exists():
280
+ return
281
+ with path.open("rb") as log:
282
+ log.seek(self.offset)
283
+ data = log.read()
284
+ self.offset += len(data)
285
+ self.query_one(Log).write(self.decoder.decode(data))
286
+
287
+ def action_switch_output(self) -> None:
288
+ if self.viewing is not None:
289
+ self.view(self.viewing, OTHER_STREAM[self.stream])
290
+
291
+ def action_leave(self) -> None:
292
+ if self.viewing is not None:
293
+ self.viewing = None
294
+ self.query_one(Log).display = False
295
+ self.query_one(DataTable).display = True
296
+ if self.idle:
297
+ self.show_summary()
298
+ else:
299
+ self.show("")
300
+ return
243
301
  field = self.query_one(Input)
244
302
  field.value = ""
245
303
  field.display = False
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes