labnotes 0.0.24__tar.gz → 0.0.26__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.24 → labnotes-0.0.26}/PKG-INFO +1 -1
  2. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/context.md +5 -2
  3. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/models.py +11 -1
  4. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/runner/describe_event.py +8 -0
  5. labnotes-0.0.26/src/labnotes/runner/run_experiment.py +138 -0
  6. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/runner/run_headless.py +5 -5
  7. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/tui/run_app.py +61 -7
  8. labnotes-0.0.24/src/labnotes/runner/run_experiment.py +0 -76
  9. {labnotes-0.0.24 → labnotes-0.0.26}/.gitignore +0 -0
  10. {labnotes-0.0.24 → labnotes-0.0.26}/CHANGELOG.md +0 -0
  11. {labnotes-0.0.24 → labnotes-0.0.26}/MIGRATIONS.md +0 -0
  12. {labnotes-0.0.24 → labnotes-0.0.26}/README.md +0 -0
  13. {labnotes-0.0.24 → labnotes-0.0.26}/pyproject.toml +0 -0
  14. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/__init__.py +0 -0
  15. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/asset_url.py +0 -0
  16. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/cell_progress.py +0 -0
  17. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/cli.py +0 -0
  18. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/context_guide.py +0 -0
  19. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/count_runs.py +0 -0
  20. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/create_app.py +0 -0
  21. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/create_experiment.py +0 -0
  22. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/data_html.py +0 -0
  23. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/default_config_text.py +0 -0
  24. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/default_counts.py +0 -0
  25. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/drain_watch.py +0 -0
  26. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/experiment_notes.py +0 -0
  27. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/experiment_progress.py +0 -0
  28. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/experiment_runs.py +0 -0
  29. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/experiments.py +0 -0
  30. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/format_value.py +0 -0
  31. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/init.py +0 -0
  32. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/list_experiments.py +0 -0
  33. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/list_runs.py +0 -0
  34. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/load_config.py +0 -0
  35. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/load_experiment.py +0 -0
  36. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/notebook_kernel.py +0 -0
  37. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/output_html.py +0 -0
  38. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/parse_run_view.py +0 -0
  39. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/pivot_cells.py +0 -0
  40. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/print_rows.py +0 -0
  41. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/python_blocks.py +0 -0
  42. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/read_config.py +0 -0
  43. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/read_path.py +0 -0
  44. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/render_markdown.py +0 -0
  45. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/runner/__init__.py +0 -0
  46. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/runner/execute_run.py +0 -0
  47. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/runner/lock_path.py +0 -0
  48. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/runner/new_run_id.py +0 -0
  49. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/runner/param_args.py +0 -0
  50. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/runner/param_grid.py +0 -0
  51. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/runner/plan_runs.py +0 -0
  52. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/runner/read_experiment_config.py +0 -0
  53. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/runner/run_folder.py +0 -0
  54. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/runner/shell_command.py +0 -0
  55. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/runner/start_experiment.py +0 -0
  56. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/runner/stop_then_kill.py +0 -0
  57. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/runner/wait_for_group.py +0 -0
  58. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/runner/write_run_json.py +0 -0
  59. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/saved_outputs.py +0 -0
  60. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/serve_app.py +0 -0
  61. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/server.py +0 -0
  62. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/sort_href.py +0 -0
  63. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/sql_quote.py +0 -0
  64. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/static/labnotes.css +0 -0
  65. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/static/row-link.js +0 -0
  66. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/static/run-cell.js +0 -0
  67. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/static/view-toggle.js +0 -0
  68. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/stop_idle_kernels.py +0 -0
  69. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/summarize_config.py +0 -0
  70. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/tables/__init__.py +0 -0
  71. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/tables/experiment_rows.py +0 -0
  72. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/tables/file_prefix.py +0 -0
  73. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/tables/iso_time.py +0 -0
  74. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/tables/list_experiment_runs.py +0 -0
  75. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/tables/open_tables.py +0 -0
  76. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/tables/path_glob.py +0 -0
  77. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/tables/path_pattern.py +0 -0
  78. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/tables/read_experiment.py +0 -0
  79. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/tables/read_file.py +0 -0
  80. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/tables/read_run.py +0 -0
  81. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/tables/summarize_experiments.py +0 -0
  82. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/templates/base.html +0 -0
  83. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/templates/experiment.html +0 -0
  84. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/templates/index.html +0 -0
  85. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/templates/macros.html +0 -0
  86. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/tui/__init__.py +0 -0
  87. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/tui/dim.py +0 -0
  88. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/tui/format_elapsed.py +0 -0
  89. {labnotes-0.0.24 → labnotes-0.0.26}/src/labnotes/tui/run_tui.py +0 -0
  90. {labnotes-0.0.24 → labnotes-0.0.26}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.24
3
+ Version: 0.0.26
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>
@@ -87,10 +87,13 @@ Runs can take a long time. Start `run` in the background, then check on it with
87
87
 
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
- `skipped` once stopped; `q` quits. Elsewhere, or with `--no-tui`, it prints a
90
+ `skipped` once stopped. Move the cursor to a pending run and press `n` to run it
91
+ next. `d` deselects a pending run so it won't run; `d` again puts it back at
92
+ the end of the queue. `+` and `-` change how many run at once; lowering it lets running runs
93
+ finish; `q` quits. Elsewhere, or with `--no-tui`, it prints a
91
94
  line per event instead.
92
95
 
93
- - `--concurrency N` runs N at a time (default 1).
96
+ - `--concurrency N` runs N at a time to start with (default 1).
94
97
  - `-e PATH` reads another file instead of `experiment.toml`.
95
98
  - Ctrl-C stops the running scripts and records their end. A second Ctrl-C
96
99
  kills them. Closing the terminal or sending SIGTERM stops them the same way,
@@ -6,7 +6,17 @@ 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
+ set_concurrency: Callable[[int], None]
16
+ toggle: Callable[[str], None]
17
+
18
+
19
+ Listen = Callable[[Controls], None]
10
20
  Start = Callable[[Report, Listen], Coroutine[Any, Any, list[dict[str, object]]]]
11
21
 
12
22
 
@@ -12,6 +12,14 @@ def describe_event(event: dict[str, object]) -> str:
12
12
  case "ended":
13
13
  outcome = event["error"] or f"exited {event['exit_code']}"
14
14
  return f"{event['run']} {outcome}"
15
+ case "queued":
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."
15
23
  case "stopping":
16
24
  return "Stopping runs. Ctrl-C again to kill them."
17
25
  case _:
@@ -0,0 +1,138 @@
1
+ import asyncio
2
+ import contextlib
3
+ import json
4
+ import os
5
+ import signal
6
+
7
+ from labnotes.models import (
8
+ Controls,
9
+ ExperimentConfig,
10
+ LabnotesConfig,
11
+ Listen,
12
+ Report,
13
+ )
14
+ from labnotes.runner.execute_run import execute_run
15
+ from labnotes.runner.new_run_id import new_run_id
16
+ from labnotes.runner.param_grid import param_grid
17
+ from labnotes.runner.plan_runs import plan_runs
18
+ from labnotes.runner.run_folder import run_folder
19
+ from labnotes.tables.list_experiment_runs import list_experiment_runs
20
+ from labnotes.tables.open_tables import open_tables
21
+
22
+ Entry = tuple[str, dict[str, object]]
23
+
24
+
25
+ async def run_experiment(
26
+ config: LabnotesConfig,
27
+ name: str,
28
+ experiment: ExperimentConfig,
29
+ concurrency: int,
30
+ report: Report,
31
+ listen: Listen,
32
+ ) -> list[dict[str, object]]:
33
+ existing = await list_experiment_runs(open_tables(config), name, experiment.counts)
34
+ finished = [json.loads(str(run["params"])) for run in existing if run["counted"]]
35
+ planned: set[str] = set()
36
+
37
+ def taken(run: str) -> bool:
38
+ return (
39
+ run in planned or run_folder(config.root, config.runs, name, run).exists()
40
+ )
41
+
42
+ plan: list[Entry] = []
43
+ for params in plan_runs(
44
+ param_grid(experiment.params), experiment.repeats, finished
45
+ ):
46
+ run = new_run_id(taken)
47
+ planned.add(run)
48
+ plan.append((run, params))
49
+ runs = [{"run": run, "params": params} for run, params in plan]
50
+ report({"event": "planned", "runs": runs, "concurrency": concurrency})
51
+ running: set[asyncio.subprocess.Process] = set()
52
+ results: list[dict[str, object]] = []
53
+ stopping = asyncio.Event()
54
+
55
+ def signal_runs(signal_number: int) -> None:
56
+ for process in running:
57
+ with contextlib.suppress(ProcessLookupError):
58
+ os.killpg(process.pid, signal_number)
59
+
60
+ def interrupt() -> None:
61
+ plan.clear()
62
+ if not stopping.is_set():
63
+ stopping.set()
64
+ report({"event": "stopping"})
65
+ signal_runs(signal.SIGTERM)
66
+ elif running:
67
+ report({"event": "killing"})
68
+ signal_runs(signal.SIGKILL)
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
+
80
+ def run_next(run: str) -> None:
81
+ entry = find(plan, run)
82
+ if entry is not None:
83
+ plan.remove(entry)
84
+ plan.insert(0, entry)
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)
138
+ 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))
@@ -1,12 +1,11 @@
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
@@ -19,6 +18,10 @@ COLUMNS = ("run", "params", "status", "time")
19
18
  class RunApp(App[None]):
20
19
  BINDINGS = [
21
20
  Binding("ctrl+c", "interrupt", "Stop runs"),
21
+ Binding("n", "run_next", "Run next"),
22
+ Binding("d", "toggle_run", "Deselect"),
23
+ Binding("plus,equals_sign", "concurrency(1)", "More at once", key_display="+"),
24
+ Binding("minus", "concurrency(-1)", "Fewer at once", key_display="-"),
22
25
  Binding("q", "quit", "Quit"),
23
26
  ]
24
27
 
@@ -27,14 +30,17 @@ class RunApp(App[None]):
27
30
  self.start = start
28
31
  self.title = title
29
32
  self.results: list[dict[str, object]] = []
30
- self.interrupt: Callable[[], None] = lambda: None
33
+ self.controls = Controls(
34
+ lambda: None, lambda run: None, lambda count: None, lambda run: None
35
+ )
36
+ self.concurrency = 1
31
37
  self.done = False
32
38
  self.started: dict[str, float] = {}
33
39
  self.pending: set[str] = set()
34
40
 
35
41
  def compose(self) -> ComposeResult:
36
42
  yield Header()
37
- yield DataTable(id="runs", cursor_type="none")
43
+ yield DataTable(id="runs", cursor_type="row")
38
44
  yield Static(id="status")
39
45
  yield Footer()
40
46
 
@@ -54,8 +60,8 @@ class RunApp(App[None]):
54
60
  passed = len(self.results) - failed
55
61
  self.show(f"{passed} passed, {failed} failed. q to quit.")
56
62
 
57
- def listen(self, interrupt: Callable[[], None]) -> None:
58
- self.interrupt = interrupt
63
+ def listen(self, controls: Controls) -> None:
64
+ self.controls = controls
59
65
  stop_then_kill(self.action_interrupt, signal.SIGTERM, signal.SIGHUP)
60
66
 
61
67
  def show_event(self, event: dict[str, object]) -> None:
@@ -68,6 +74,7 @@ class RunApp(App[None]):
68
74
  cells = (planned["run"], args, "pending", "")
69
75
  table.add_row(*map(dim, cells), key=planned["run"])
70
76
  self.pending.add(planned["run"])
77
+ self.show_concurrency(event)
71
78
  self.show(describe_event(event))
72
79
  case "started":
73
80
  self.pending.discard(run)
@@ -81,6 +88,23 @@ class RunApp(App[None]):
81
88
  table.update_cell(run, "status", outcome, update_width=True)
82
89
  self.tick()
83
90
  del self.started[run]
91
+ case "queued":
92
+ self.reorder(event)
93
+ table.move_cursor(row=table.get_row_index(run))
94
+ self.show(describe_event(event))
95
+ case "deselected":
96
+ self.pending.discard(run)
97
+ table.update_cell(run, "status", dim("deselected"))
98
+ self.reorder(event)
99
+ self.show(describe_event(event))
100
+ case "reselected":
101
+ self.pending.add(run)
102
+ table.update_cell(run, "status", dim("pending"))
103
+ self.reorder(event)
104
+ self.show(describe_event(event))
105
+ case "concurrency":
106
+ self.show_concurrency(event)
107
+ self.show(describe_event(event))
84
108
  case "stopping":
85
109
  for pending in self.pending:
86
110
  table.update_cell(pending, "status", dim("skipped"))
@@ -88,6 +112,18 @@ class RunApp(App[None]):
88
112
  case _:
89
113
  self.show(describe_event(event))
90
114
 
115
+ def reorder(self, event: dict[str, object]) -> None:
116
+ table = self.query_one(DataTable)
117
+ waiting: list[str] = [*event["pending"], *event["held"]] # type: ignore[misc]
118
+ for run in waiting:
119
+ row = table.get_row(run)
120
+ table.remove_row(run)
121
+ table.add_row(*row, key=run)
122
+
123
+ def show_concurrency(self, event: dict[str, object]) -> None:
124
+ self.concurrency = int(event["concurrency"]) # type: ignore[call-overload]
125
+ self.sub_title = f"{self.concurrency} at a time"
126
+
91
127
  def tick(self) -> None:
92
128
  table = self.query_one(DataTable)
93
129
  for run, began in self.started.items():
@@ -102,10 +138,28 @@ class RunApp(App[None]):
102
138
  if self.done:
103
139
  self.exit()
104
140
  else:
105
- self.interrupt()
141
+ self.controls.interrupt()
106
142
 
107
143
  async def action_quit(self) -> None:
108
144
  if self.done:
109
145
  self.exit()
110
146
  else:
111
147
  self.show("Runs are still going. Ctrl-C stops them.")
148
+
149
+ def cursor_run(self) -> str | None:
150
+ table = self.query_one(DataTable)
151
+ if not table.row_count:
152
+ return None
153
+ cell = table.coordinate_to_cell_key(table.cursor_coordinate)
154
+ return str(cell.row_key.value)
155
+
156
+ def action_run_next(self) -> None:
157
+ if (run := self.cursor_run()) is not None:
158
+ self.controls.run_next(run)
159
+
160
+ def action_toggle_run(self) -> None:
161
+ if (run := self.cursor_run()) is not None:
162
+ self.controls.toggle(run)
163
+
164
+ def action_concurrency(self, change: int) -> None:
165
+ self.controls.set_concurrency(self.concurrency + change)
@@ -1,76 +0,0 @@
1
- import asyncio
2
- import contextlib
3
- import json
4
- import os
5
- import signal
6
-
7
- from labnotes.models import ExperimentConfig, LabnotesConfig, Listen, Report
8
- from labnotes.runner.execute_run import execute_run
9
- from labnotes.runner.new_run_id import new_run_id
10
- from labnotes.runner.param_grid import param_grid
11
- from labnotes.runner.plan_runs import plan_runs
12
- from labnotes.runner.run_folder import run_folder
13
- from labnotes.tables.list_experiment_runs import list_experiment_runs
14
- from labnotes.tables.open_tables import open_tables
15
-
16
-
17
- async def run_experiment(
18
- config: LabnotesConfig,
19
- name: str,
20
- experiment: ExperimentConfig,
21
- concurrency: int,
22
- report: Report,
23
- listen: Listen,
24
- ) -> list[dict[str, object]]:
25
- existing = await list_experiment_runs(open_tables(config), name, experiment.counts)
26
- finished = [json.loads(str(run["params"])) for run in existing if run["counted"]]
27
- planned: set[str] = set()
28
-
29
- def taken(run: str) -> bool:
30
- return (
31
- run in planned or run_folder(config.root, config.runs, name, run).exists()
32
- )
33
-
34
- plan: list[tuple[str, dict[str, object]]] = []
35
- for params in plan_runs(
36
- param_grid(experiment.params), experiment.repeats, finished
37
- ):
38
- run = new_run_id(taken)
39
- planned.add(run)
40
- plan.append((run, params))
41
- runs = [{"run": run, "params": params} for run, params in plan]
42
- report({"event": "planned", "runs": runs, "concurrency": concurrency})
43
- running: set[asyncio.subprocess.Process] = set()
44
- results: list[dict[str, object]] = []
45
- stopping = asyncio.Event()
46
-
47
- def signal_runs(signal_number: int) -> None:
48
- for process in running:
49
- with contextlib.suppress(ProcessLookupError):
50
- os.killpg(process.pid, signal_number)
51
-
52
- def interrupt() -> None:
53
- plan.clear()
54
- if not stopping.is_set():
55
- stopping.set()
56
- report({"event": "stopping"})
57
- signal_runs(signal.SIGTERM)
58
- elif running:
59
- report({"event": "killing"})
60
- signal_runs(signal.SIGKILL)
61
-
62
- async def work() -> None:
63
- while plan:
64
- run, params = plan.pop(0)
65
- folder = run_folder(config.root, config.runs, name, run)
66
- folder.mkdir(parents=True)
67
- report({"event": "started", "run": run, "params": params})
68
- result = await execute_run(
69
- config.root, folder, experiment.command, params, running, stopping
70
- )
71
- report({"event": "ended", "run": run, **result})
72
- results.append(result)
73
-
74
- listen(interrupt)
75
- await asyncio.gather(*(work() for _ in range(concurrency)))
76
- return results
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes