labnotes 0.0.23__tar.gz → 0.0.25__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 (89) hide show
  1. {labnotes-0.0.23 → labnotes-0.0.25}/PKG-INFO +1 -1
  2. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/context.md +3 -1
  3. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/models.py +9 -1
  4. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/runner/describe_event.py +4 -1
  5. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/runner/run_experiment.py +35 -10
  6. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/runner/run_headless.py +5 -5
  7. labnotes-0.0.25/src/labnotes/tui/dim.py +5 -0
  8. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/tui/run_app.py +40 -9
  9. {labnotes-0.0.23 → labnotes-0.0.25}/.gitignore +0 -0
  10. {labnotes-0.0.23 → labnotes-0.0.25}/CHANGELOG.md +0 -0
  11. {labnotes-0.0.23 → labnotes-0.0.25}/MIGRATIONS.md +0 -0
  12. {labnotes-0.0.23 → labnotes-0.0.25}/README.md +0 -0
  13. {labnotes-0.0.23 → labnotes-0.0.25}/pyproject.toml +0 -0
  14. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/__init__.py +0 -0
  15. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/asset_url.py +0 -0
  16. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/cell_progress.py +0 -0
  17. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/cli.py +0 -0
  18. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/context_guide.py +0 -0
  19. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/count_runs.py +0 -0
  20. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/create_app.py +0 -0
  21. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/create_experiment.py +0 -0
  22. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/data_html.py +0 -0
  23. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/default_config_text.py +0 -0
  24. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/default_counts.py +0 -0
  25. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/drain_watch.py +0 -0
  26. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/experiment_notes.py +0 -0
  27. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/experiment_progress.py +0 -0
  28. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/experiment_runs.py +0 -0
  29. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/experiments.py +0 -0
  30. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/format_value.py +0 -0
  31. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/init.py +0 -0
  32. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/list_experiments.py +0 -0
  33. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/list_runs.py +0 -0
  34. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/load_config.py +0 -0
  35. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/load_experiment.py +0 -0
  36. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/notebook_kernel.py +0 -0
  37. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/output_html.py +0 -0
  38. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/parse_run_view.py +0 -0
  39. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/pivot_cells.py +0 -0
  40. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/print_rows.py +0 -0
  41. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/python_blocks.py +0 -0
  42. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/read_config.py +0 -0
  43. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/read_path.py +0 -0
  44. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/render_markdown.py +0 -0
  45. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/runner/__init__.py +0 -0
  46. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/runner/execute_run.py +0 -0
  47. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/runner/lock_path.py +0 -0
  48. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/runner/new_run_id.py +0 -0
  49. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/runner/param_args.py +0 -0
  50. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/runner/param_grid.py +0 -0
  51. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/runner/plan_runs.py +0 -0
  52. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/runner/read_experiment_config.py +0 -0
  53. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/runner/run_folder.py +0 -0
  54. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/runner/shell_command.py +0 -0
  55. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/runner/start_experiment.py +0 -0
  56. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/runner/stop_then_kill.py +0 -0
  57. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/runner/wait_for_group.py +0 -0
  58. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/runner/write_run_json.py +0 -0
  59. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/saved_outputs.py +0 -0
  60. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/serve_app.py +0 -0
  61. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/server.py +0 -0
  62. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/sort_href.py +0 -0
  63. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/sql_quote.py +0 -0
  64. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/static/labnotes.css +0 -0
  65. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/static/row-link.js +0 -0
  66. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/static/run-cell.js +0 -0
  67. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/static/view-toggle.js +0 -0
  68. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/stop_idle_kernels.py +0 -0
  69. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/summarize_config.py +0 -0
  70. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/tables/__init__.py +0 -0
  71. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/tables/experiment_rows.py +0 -0
  72. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/tables/file_prefix.py +0 -0
  73. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/tables/iso_time.py +0 -0
  74. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/tables/list_experiment_runs.py +0 -0
  75. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/tables/open_tables.py +0 -0
  76. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/tables/path_glob.py +0 -0
  77. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/tables/path_pattern.py +0 -0
  78. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/tables/read_experiment.py +0 -0
  79. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/tables/read_file.py +0 -0
  80. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/tables/read_run.py +0 -0
  81. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/tables/summarize_experiments.py +0 -0
  82. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/templates/base.html +0 -0
  83. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/templates/experiment.html +0 -0
  84. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/templates/index.html +0 -0
  85. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/templates/macros.html +0 -0
  86. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/tui/__init__.py +0 -0
  87. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/tui/format_elapsed.py +0 -0
  88. {labnotes-0.0.23 → labnotes-0.0.25}/src/labnotes/tui/run_tui.py +0 -0
  89. {labnotes-0.0.23 → labnotes-0.0.25}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.23
3
+ Version: 0.0.25
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,7 +86,9 @@ A script run by labnotes should:
86
86
  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.
87
87
 
88
88
  In a terminal, `run` shows a live table of runs (status and time) and stays
89
- open when they finish; `q` quits. Elsewhere, or with `--no-tui`, it prints a
89
+ open when they finish. Runs not started yet show grayed as `pending`, and as
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
90
92
  line per event instead.
91
93
 
92
94
  - `--concurrency N` runs N at a time (default 1).
@@ -6,7 +6,15 @@ from typing import Any
6
6
  from labnotes.default_counts import DEFAULT_COUNTS
7
7
 
8
8
  Report = Callable[[dict[str, object]], None]
9
- Listen = Callable[[Callable[[], None]], None]
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Controls:
13
+ interrupt: Callable[[], None]
14
+ run_next: Callable[[str], None]
15
+
16
+
17
+ Listen = Callable[[Controls], None]
10
18
  Start = Callable[[Report, Listen], Coroutine[Any, Any, list[dict[str, object]]]]
11
19
 
12
20
 
@@ -4,13 +4,16 @@ from labnotes.runner.param_args import param_args
4
4
  def describe_event(event: dict[str, object]) -> str:
5
5
  match event["event"]:
6
6
  case "planned":
7
- return f"{event['runs']} runs to go, {event['concurrency']} at a time."
7
+ runs = len(event["runs"]) # type: ignore[arg-type]
8
+ return f"{runs} runs to go, {event['concurrency']} at a time."
8
9
  case "started":
9
10
  args = " ".join(param_args(event["params"])) # type: ignore[arg-type]
10
11
  return f"{event['run']} started {args}".rstrip()
11
12
  case "ended":
12
13
  outcome = event["error"] or f"exited {event['exit_code']}"
13
14
  return f"{event['run']} {outcome}"
15
+ case "queued":
16
+ return f"{event['run']} runs next."
14
17
  case "stopping":
15
18
  return "Stopping runs. Ctrl-C again to kill them."
16
19
  case _:
@@ -4,7 +4,13 @@ import json
4
4
  import os
5
5
  import signal
6
6
 
7
- from labnotes.models import ExperimentConfig, LabnotesConfig, Listen, Report
7
+ from labnotes.models import (
8
+ Controls,
9
+ ExperimentConfig,
10
+ LabnotesConfig,
11
+ Listen,
12
+ Report,
13
+ )
8
14
  from labnotes.runner.execute_run import execute_run
9
15
  from labnotes.runner.new_run_id import new_run_id
10
16
  from labnotes.runner.param_grid import param_grid
@@ -22,10 +28,24 @@ async def run_experiment(
22
28
  report: Report,
23
29
  listen: Listen,
24
30
  ) -> list[dict[str, object]]:
25
- runs = await list_experiment_runs(open_tables(config), name, experiment.counts)
26
- finished = [json.loads(str(run["params"])) for run in runs if run["counted"]]
27
- plan = plan_runs(param_grid(experiment.params), experiment.repeats, finished)
28
- report({"event": "planned", "runs": len(plan), "concurrency": concurrency})
31
+ existing = await list_experiment_runs(open_tables(config), name, experiment.counts)
32
+ finished = [json.loads(str(run["params"])) for run in existing if run["counted"]]
33
+ planned: set[str] = set()
34
+
35
+ def taken(run: str) -> bool:
36
+ return (
37
+ run in planned or run_folder(config.root, config.runs, name, run).exists()
38
+ )
39
+
40
+ plan: list[tuple[str, dict[str, object]]] = []
41
+ for params in plan_runs(
42
+ param_grid(experiment.params), experiment.repeats, finished
43
+ ):
44
+ run = new_run_id(taken)
45
+ planned.add(run)
46
+ plan.append((run, params))
47
+ runs = [{"run": run, "params": params} for run, params in plan]
48
+ report({"event": "planned", "runs": runs, "concurrency": concurrency})
29
49
  running: set[asyncio.subprocess.Process] = set()
30
50
  results: list[dict[str, object]] = []
31
51
  stopping = asyncio.Event()
@@ -45,12 +65,17 @@ async def run_experiment(
45
65
  report({"event": "killing"})
46
66
  signal_runs(signal.SIGKILL)
47
67
 
68
+ def run_next(run: str) -> None:
69
+ entry = next((entry for entry in plan if entry[0] == run), None)
70
+ if entry is not None:
71
+ plan.remove(entry)
72
+ plan.insert(0, entry)
73
+ pending = [id_ for id_, _ in plan]
74
+ report({"event": "queued", "run": run, "pending": pending})
75
+
48
76
  async def work() -> None:
49
77
  while plan:
50
- params = plan.pop(0)
51
- run = new_run_id(
52
- lambda id_: run_folder(config.root, config.runs, name, id_).exists()
53
- )
78
+ run, params = plan.pop(0)
54
79
  folder = run_folder(config.root, config.runs, name, run)
55
80
  folder.mkdir(parents=True)
56
81
  report({"event": "started", "run": run, "params": params})
@@ -60,6 +85,6 @@ async def run_experiment(
60
85
  report({"event": "ended", "run": run, **result})
61
86
  results.append(result)
62
87
 
63
- listen(interrupt)
88
+ listen(Controls(interrupt, run_next))
64
89
  await asyncio.gather(*(work() for _ in range(concurrency)))
65
90
  return results
@@ -1,17 +1,17 @@
1
1
  import asyncio
2
2
  import signal
3
- from collections.abc import Callable
4
3
 
5
4
  import click
6
5
 
7
- from labnotes.models import Start
6
+ from labnotes.models import Controls, Start
8
7
  from labnotes.runner.describe_event import describe_event
9
8
  from labnotes.runner.stop_then_kill import stop_then_kill
10
9
 
11
10
 
12
11
  def run_headless(start: Start) -> list[dict[str, object]]:
13
- def listen(interrupt: Callable[[], None]) -> None:
14
- asyncio.get_running_loop().add_signal_handler(signal.SIGINT, interrupt)
15
- stop_then_kill(interrupt, signal.SIGTERM, signal.SIGHUP)
12
+ def listen(controls: Controls) -> None:
13
+ loop = asyncio.get_running_loop()
14
+ loop.add_signal_handler(signal.SIGINT, controls.interrupt)
15
+ stop_then_kill(controls.interrupt, signal.SIGTERM, signal.SIGHUP)
16
16
 
17
17
  return asyncio.run(start(lambda event: click.echo(describe_event(event)), listen))
@@ -0,0 +1,5 @@
1
+ from rich.text import Text
2
+
3
+
4
+ def dim(cell: str) -> Text:
5
+ return Text(cell, style="dim")
@@ -1,21 +1,24 @@
1
1
  import signal
2
2
  import time
3
- from collections.abc import Callable
4
3
 
5
4
  from textual.app import App, ComposeResult
6
5
  from textual.binding import Binding
7
6
  from textual.widgets import DataTable, Footer, Header, Static
8
7
 
9
- from labnotes.models import Start
8
+ from labnotes.models import Controls, Start
10
9
  from labnotes.runner.describe_event import describe_event
11
10
  from labnotes.runner.stop_then_kill import stop_then_kill
12
11
  from labnotes.runner.param_args import param_args
12
+ from labnotes.tui.dim import dim
13
13
  from labnotes.tui.format_elapsed import format_elapsed
14
14
 
15
+ COLUMNS = ("run", "params", "status", "time")
16
+
15
17
 
16
18
  class RunApp(App[None]):
17
19
  BINDINGS = [
18
20
  Binding("ctrl+c", "interrupt", "Stop runs"),
21
+ Binding("n", "run_next", "Run next"),
19
22
  Binding("q", "quit", "Quit"),
20
23
  ]
21
24
 
@@ -24,19 +27,20 @@ class RunApp(App[None]):
24
27
  self.start = start
25
28
  self.title = title
26
29
  self.results: list[dict[str, object]] = []
27
- self.interrupt: Callable[[], None] = lambda: None
30
+ self.controls = Controls(lambda: None, lambda run: None)
28
31
  self.done = False
29
32
  self.started: dict[str, float] = {}
33
+ self.pending: set[str] = set()
30
34
 
31
35
  def compose(self) -> ComposeResult:
32
36
  yield Header()
33
- yield DataTable(id="runs", cursor_type="none")
37
+ yield DataTable(id="runs", cursor_type="row")
34
38
  yield Static(id="status")
35
39
  yield Footer()
36
40
 
37
41
  def on_mount(self) -> None:
38
42
  table = self.query_one(DataTable)
39
- for column in ("run", "params", "status", "time"):
43
+ for column in COLUMNS:
40
44
  table.add_column(column, key=column)
41
45
 
42
46
  def on_ready(self) -> None:
@@ -50,23 +54,44 @@ class RunApp(App[None]):
50
54
  passed = len(self.results) - failed
51
55
  self.show(f"{passed} passed, {failed} failed. q to quit.")
52
56
 
53
- def listen(self, interrupt: Callable[[], None]) -> None:
54
- self.interrupt = interrupt
57
+ def listen(self, controls: Controls) -> None:
58
+ self.controls = controls
55
59
  stop_then_kill(self.action_interrupt, signal.SIGTERM, signal.SIGHUP)
56
60
 
57
61
  def show_event(self, event: dict[str, object]) -> None:
58
62
  table = self.query_one(DataTable)
59
63
  run = str(event.get("run"))
60
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))
61
72
  case "started":
73
+ self.pending.discard(run)
62
74
  self.started[run] = time.monotonic()
63
75
  args = " ".join(param_args(event["params"])) # type: ignore[arg-type]
64
- table.add_row(run, args, "running", "0s", key=run)
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)
65
79
  case "ended":
66
80
  outcome = event["error"] or f"exited {event['exit_code']}"
67
81
  table.update_cell(run, "status", outcome, update_width=True)
68
82
  self.tick()
69
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))
70
95
  case _:
71
96
  self.show(describe_event(event))
72
97
 
@@ -84,10 +109,16 @@ class RunApp(App[None]):
84
109
  if self.done:
85
110
  self.exit()
86
111
  else:
87
- self.interrupt()
112
+ self.controls.interrupt()
88
113
 
89
114
  async def action_quit(self) -> None:
90
115
  if self.done:
91
116
  self.exit()
92
117
  else:
93
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