labnotes 0.0.35__tar.gz → 0.0.36__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.36}/PKG-INFO +1 -1
  2. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/context.md +3 -1
  3. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/runner/run_experiment.py +1 -1
  4. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/tui/run_app.py +61 -6
  5. {labnotes-0.0.35 → labnotes-0.0.36}/.gitignore +0 -0
  6. {labnotes-0.0.35 → labnotes-0.0.36}/CHANGELOG.md +0 -0
  7. {labnotes-0.0.35 → labnotes-0.0.36}/MIGRATIONS.md +0 -0
  8. {labnotes-0.0.35 → labnotes-0.0.36}/README.md +0 -0
  9. {labnotes-0.0.35 → labnotes-0.0.36}/pyproject.toml +0 -0
  10. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/__init__.py +0 -0
  11. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/asset_url.py +0 -0
  12. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/cell_progress.py +0 -0
  13. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/check_counts.py +0 -0
  14. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/cli.py +0 -0
  15. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/context_guide.py +0 -0
  16. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/count_runs.py +0 -0
  17. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/create_app.py +0 -0
  18. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/create_experiment.py +0 -0
  19. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/data_html.py +0 -0
  20. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/default_config_text.py +0 -0
  21. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/default_counts.py +0 -0
  22. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/drain_watch.py +0 -0
  23. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/experiment_notes.py +0 -0
  24. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/experiment_progress.py +0 -0
  25. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/experiment_runs.py +0 -0
  26. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/experiments.py +0 -0
  27. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/format_value.py +0 -0
  28. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/init.py +0 -0
  29. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/list_experiments.py +0 -0
  30. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/list_runs.py +0 -0
  31. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/load_config.py +0 -0
  32. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/load_experiment.py +0 -0
  33. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/models.py +0 -0
  34. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/notebook_kernel.py +0 -0
  35. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/output_html.py +0 -0
  36. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/parse_run_view.py +0 -0
  37. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/pivot_cells.py +0 -0
  38. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/print_rows.py +0 -0
  39. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/python_blocks.py +0 -0
  40. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/read_config.py +0 -0
  41. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/read_path.py +0 -0
  42. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/render_markdown.py +0 -0
  43. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/runner/__init__.py +0 -0
  44. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/runner/describe_event.py +0 -0
  45. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/runner/execute_run.py +0 -0
  46. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/runner/hold_folder.py +0 -0
  47. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/runner/new_invocation_id.py +0 -0
  48. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/runner/new_run_id.py +0 -0
  49. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/runner/param_args.py +0 -0
  50. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/runner/param_grid.py +0 -0
  51. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/runner/plan_runs.py +0 -0
  52. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/runner/read_experiment_config.py +0 -0
  53. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/runner/run_folder.py +0 -0
  54. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/runner/run_headless.py +0 -0
  55. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/runner/runs_parent.py +0 -0
  56. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/runner/shell_command.py +0 -0
  57. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/runner/start_experiment.py +0 -0
  58. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/runner/stop_then_kill.py +0 -0
  59. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/runner/wait_for_group.py +0 -0
  60. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/runner/write_run_json.py +0 -0
  61. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/saved_outputs.py +0 -0
  62. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/serve_app.py +0 -0
  63. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/server.py +0 -0
  64. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/sort_href.py +0 -0
  65. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/sql_quote.py +0 -0
  66. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/static/labnotes.css +0 -0
  67. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/static/row-link.js +0 -0
  68. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/static/run-cell.js +0 -0
  69. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/static/view-toggle.js +0 -0
  70. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/stop_idle_kernels.py +0 -0
  71. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/summarize_config.py +0 -0
  72. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/tables/__init__.py +0 -0
  73. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/tables/experiment_rows.py +0 -0
  74. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/tables/file_prefix.py +0 -0
  75. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/tables/iso_time.py +0 -0
  76. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/tables/list_experiment_runs.py +0 -0
  77. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/tables/open_tables.py +0 -0
  78. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/tables/path_glob.py +0 -0
  79. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/tables/path_pattern.py +0 -0
  80. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/tables/read_experiment.py +0 -0
  81. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/tables/read_file.py +0 -0
  82. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/tables/read_run.py +0 -0
  83. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/tables/summarize_experiments.py +0 -0
  84. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/templates/base.html +0 -0
  85. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/templates/experiment.html +0 -0
  86. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/templates/index.html +0 -0
  87. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/templates/macros.html +0 -0
  88. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/tui/__init__.py +0 -0
  89. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/tui/dim.py +0 -0
  90. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/tui/format_elapsed.py +0 -0
  91. {labnotes-0.0.35 → labnotes-0.0.36}/src/labnotes/tui/run_tui.py +0 -0
  92. {labnotes-0.0.35 → labnotes-0.0.36}/testing-conventions.toml +0 -0
  93. {labnotes-0.0.35 → labnotes-0.0.36}/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.36
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,8 @@ 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. `q` quits. Elsewhere, or with `--no-tui`, it prints a
102
104
  line per event instead.
103
105
 
104
106
  - `--concurrency N` runs N at a time to start with (default 1).
@@ -166,7 +166,7 @@ async def run_experiment(
166
166
  async def start(run: str, params: dict[str, object]) -> None:
167
167
  folder = run_folder(config.root, config.runs, name, run)
168
168
  folder.mkdir(parents=True)
169
- report({"event": "started", "run": run, "params": params})
169
+ report({"event": "started", "run": run, "params": params, "folder": folder})
170
170
  running[run] = set()
171
171
  result = await execute_run(
172
172
  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:
@@ -99,6 +111,7 @@ class RunApp(App[None]):
99
111
  self.idle = False
100
112
  self.pending.discard(run)
101
113
  self.started[run] = time.monotonic()
114
+ self.folders[run] = Path(str(event["folder"]))
102
115
  if run == self.next_run:
103
116
  self.show("")
104
117
  args = " ".join(param_args(event["params"])) # type: ignore[arg-type]
@@ -139,9 +152,7 @@ class RunApp(App[None]):
139
152
  self.show(describe_event(event))
140
153
  case "idle":
141
154
  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.")
155
+ self.show_summary()
145
156
  case "concurrency":
146
157
  self.show_concurrency(event)
147
158
  self.show(describe_event(event))
@@ -152,6 +163,11 @@ class RunApp(App[None]):
152
163
  case _:
153
164
  self.show(describe_event(event))
154
165
 
166
+ def show_summary(self) -> None:
167
+ failed = len(self.failed)
168
+ passed = len(self.ended) - failed
169
+ self.show(f"{passed} passed, {failed} failed. q to quit.")
170
+
155
171
  def reorder(self, event: dict[str, object], row: int) -> None:
156
172
  waiting: list[str] = [*event["pending"], *event["held"]] # type: ignore[misc]
157
173
  for run in waiting:
@@ -239,7 +255,46 @@ class RunApp(App[None]):
239
255
  field.display = True
240
256
  field.focus()
241
257
 
242
- def action_clear_filter(self) -> None:
258
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
259
+ self.view(str(event.row_key.value), "stdout")
260
+
261
+ def view(self, run: str, stream: str) -> None:
262
+ self.viewing = run
263
+ self.stream = stream
264
+ self.offset = 0
265
+ self.decoder.reset()
266
+ self.query_one(Log).clear()
267
+ self.query_one(DataTable).display = False
268
+ self.query_one(Log).display = True
269
+ self.show(f"{run} {stream}. e switches, Escape goes back.")
270
+ self.follow()
271
+
272
+ def follow(self) -> None:
273
+ if self.viewing not in self.folders:
274
+ return
275
+ path = self.folders[self.viewing] / f"{self.stream}.log"
276
+ if not path.exists():
277
+ return
278
+ with path.open("rb") as log:
279
+ log.seek(self.offset)
280
+ data = log.read()
281
+ self.offset += len(data)
282
+ self.query_one(Log).write(self.decoder.decode(data))
283
+
284
+ def action_switch_output(self) -> None:
285
+ if self.viewing is not None:
286
+ self.view(self.viewing, OTHER_STREAM[self.stream])
287
+
288
+ def action_leave(self) -> None:
289
+ if self.viewing is not None:
290
+ self.viewing = None
291
+ self.query_one(Log).display = False
292
+ self.query_one(DataTable).display = True
293
+ if self.idle:
294
+ self.show_summary()
295
+ else:
296
+ self.show("")
297
+ return
243
298
  field = self.query_one(Input)
244
299
  field.value = ""
245
300
  field.display = False
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes