labnotes 0.0.69__tar.gz → 0.0.71__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 (131) hide show
  1. {labnotes-0.0.69 → labnotes-0.0.71}/PKG-INFO +3 -1
  2. {labnotes-0.0.69 → labnotes-0.0.71}/pyproject.toml +3 -1
  3. labnotes-0.0.71/src/labnotes/__main__.py +3 -0
  4. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/cli.py +4 -0
  5. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/context.md +12 -6
  6. labnotes-0.0.71/src/labnotes/daemon/daemon_app.py +154 -0
  7. labnotes-0.0.71/src/labnotes/daemon/daemon_client.py +12 -0
  8. labnotes-0.0.71/src/labnotes/daemon/daemon_running.py +14 -0
  9. labnotes-0.0.71/src/labnotes/daemon/daemon_server.py +12 -0
  10. labnotes-0.0.71/src/labnotes/daemon/ensure_daemon.py +26 -0
  11. labnotes-0.0.71/src/labnotes/daemon/exit_when_idle.py +15 -0
  12. labnotes-0.0.71/src/labnotes/daemon/remote_start.py +70 -0
  13. labnotes-0.0.71/src/labnotes/daemon/run_daemon.py +27 -0
  14. labnotes-0.0.71/src/labnotes/daemon/serve_daemon.py +16 -0
  15. labnotes-0.0.71/src/labnotes/daemon/socket_path.py +7 -0
  16. labnotes-0.0.71/src/labnotes/daemon/stop_runs.py +12 -0
  17. labnotes-0.0.71/src/labnotes/daemon_command.py +14 -0
  18. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/experiments.py +6 -6
  19. labnotes-0.0.71/src/labnotes/run_command.py +26 -0
  20. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/execute_run.py +7 -7
  21. labnotes-0.0.71/src/labnotes/runner/prepare_experiment.py +23 -0
  22. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/run_experiment.py +9 -5
  23. labnotes-0.0.71/src/labnotes/runner/signal_tree.py +12 -0
  24. labnotes-0.0.71/src/labnotes/runner/wait_for_tree.py +8 -0
  25. labnotes-0.0.71/src/labnotes/tui/__init__.py +0 -0
  26. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tui/run_app.py +5 -2
  27. {labnotes-0.0.69 → labnotes-0.0.71}/testing-conventions.toml +18 -0
  28. {labnotes-0.0.69 → labnotes-0.0.71}/uv.lock +15 -2
  29. labnotes-0.0.69/src/labnotes/runner/start_experiment.py +0 -39
  30. labnotes-0.0.69/src/labnotes/runner/wait_for_group.py +0 -11
  31. {labnotes-0.0.69 → labnotes-0.0.71}/.gitignore +0 -0
  32. {labnotes-0.0.69 → labnotes-0.0.71}/CHANGELOG.md +0 -0
  33. {labnotes-0.0.69 → labnotes-0.0.71}/MIGRATIONS.md +0 -0
  34. {labnotes-0.0.69 → labnotes-0.0.71}/README.md +0 -0
  35. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/__init__.py +0 -0
  36. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/asset_url.py +0 -0
  37. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/check_counts.py +0 -0
  38. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/context_guide.py +0 -0
  39. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/count_runs.py +0 -0
  40. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/create_app.py +0 -0
  41. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/create_experiment.py +0 -0
  42. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/create_note.py +0 -0
  43. {labnotes-0.0.69/src/labnotes/runner → labnotes-0.0.71/src/labnotes/daemon}/__init__.py +0 -0
  44. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/data_html.py +0 -0
  45. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/default_config_text.py +0 -0
  46. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/default_counts.py +0 -0
  47. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/drain_watch.py +0 -0
  48. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/experiment_notes.py +0 -0
  49. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/experiment_runs.py +0 -0
  50. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/file_changes.py +0 -0
  51. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/format_duration.py +0 -0
  52. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/format_value.py +0 -0
  53. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/group_runs.py +0 -0
  54. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/init.py +0 -0
  55. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/invocation_started.py +0 -0
  56. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/is_positive_int.py +0 -0
  57. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/list_experiments.py +0 -0
  58. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/list_runs.py +0 -0
  59. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/load_config.py +0 -0
  60. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/load_experiment.py +0 -0
  61. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/models.py +0 -0
  62. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/modes.py +0 -0
  63. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/nests_runs.py +0 -0
  64. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/note_path.py +0 -0
  65. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/notebook_kernel.py +0 -0
  66. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/output_html.py +0 -0
  67. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/parse_run_view.py +0 -0
  68. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/print_rows.py +0 -0
  69. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/python_blocks.py +0 -0
  70. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/read_config.py +0 -0
  71. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/read_path.py +0 -0
  72. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/render_markdown.py +0 -0
  73. {labnotes-0.0.69/src/labnotes/tables → labnotes-0.0.71/src/labnotes/runner}/__init__.py +0 -0
  74. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/describe_event.py +0 -0
  75. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/hold_folder.py +0 -0
  76. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/new_param_run_id.py +0 -0
  77. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/new_run_id.py +0 -0
  78. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  79. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/param_args.py +0 -0
  80. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/param_grid.py +0 -0
  81. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/param_run_name.py +0 -0
  82. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/plan_runs.py +0 -0
  83. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/read_experiment_config.py +0 -0
  84. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/run_folder.py +0 -0
  85. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/run_headless.py +0 -0
  86. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/runs_parent.py +0 -0
  87. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/shell_command.py +0 -0
  88. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/stop_then_kill.py +0 -0
  89. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/write_run_json.py +0 -0
  90. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/save_conflict_copy.py +0 -0
  91. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/save_note.py +0 -0
  92. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/saved_outputs.py +0 -0
  93. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/serve_app.py +0 -0
  94. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/server.py +0 -0
  95. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/sort_href.py +0 -0
  96. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/sql_quote.py +0 -0
  97. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/static/experiment.js +0 -0
  98. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/static/labnotes.css +0 -0
  99. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/static/new-note.js +0 -0
  100. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/static/note-edit.js +0 -0
  101. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/static/note-editor.js +0 -0
  102. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/static/row-link.js +0 -0
  103. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/static/run-cell.js +0 -0
  104. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/static/view-toggle.js +0 -0
  105. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/summarize_config.py +0 -0
  106. {labnotes-0.0.69/src/labnotes/tui → labnotes-0.0.71/src/labnotes/tables}/__init__.py +0 -0
  107. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/experiment_rows.py +0 -0
  108. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/experiment_run_ids.py +0 -0
  109. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/file_prefix.py +0 -0
  110. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/file_text.py +0 -0
  111. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/iso_time.py +0 -0
  112. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/list_experiment_runs.py +0 -0
  113. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/note_text.py +0 -0
  114. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/open_tables.py +0 -0
  115. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/path_glob.py +0 -0
  116. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/path_pattern.py +0 -0
  117. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/read_experiment.py +0 -0
  118. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/read_file.py +0 -0
  119. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/read_run.py +0 -0
  120. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/summarize_experiments.py +0 -0
  121. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/templates/base.html +0 -0
  122. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/templates/editor_imports.html +0 -0
  123. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/templates/experiment.html +0 -0
  124. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/templates/index.html +0 -0
  125. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/templates/note.html +0 -0
  126. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tui/dim.py +0 -0
  127. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tui/format_elapsed.py +0 -0
  128. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tui/run_tui.py +0 -0
  129. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tui/runs_table.py +0 -0
  130. {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/wait_for_note.py +0 -0
  131. {labnotes-0.0.69 → labnotes-0.0.71}/tests/integration/conftest.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.69
3
+ Version: 0.0.71
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>
@@ -9,10 +9,12 @@ Requires-Python: >=3.12
9
9
  Requires-Dist: click>=8.5.0
10
10
  Requires-Dist: dirsql>=0.4.110
11
11
  Requires-Dist: haikunator>=2.1.0
12
+ Requires-Dist: httpx2>=2.13.1
12
13
  Requires-Dist: ipykernel>=7.4.0
13
14
  Requires-Dist: jinja2>=3.1.6
14
15
  Requires-Dist: jupyter-client>=8.10.0
15
16
  Requires-Dist: markdown-it-py>=4.2.0
17
+ Requires-Dist: psutil>=7.2.2
16
18
  Requires-Dist: rich>=14
17
19
  Requires-Dist: starlette>=1.8.0
18
20
  Requires-Dist: textual>=8.2.8
@@ -14,10 +14,12 @@ dependencies = [
14
14
  "click>=8.5.0",
15
15
  "dirsql>=0.4.110",
16
16
  "haikunator>=2.1.0",
17
+ "httpx2>=2.13.1",
17
18
  "ipykernel>=7.4.0",
18
19
  "jinja2>=3.1.6",
19
20
  "jupyter-client>=8.10.0",
20
21
  "markdown-it-py>=4.2.0",
22
+ "psutil>=7.2.2",
21
23
  "rich>=14",
22
24
  "starlette>=1.8.0",
23
25
  "textual>=8.2.8",
@@ -33,11 +35,11 @@ labnotes = "labnotes.cli:main"
33
35
  [dependency-groups]
34
36
  dev = [
35
37
  "curtaincall>=0.3.0",
36
- "httpx2>=2.13.1",
37
38
  "mypy>=2.4.0",
38
39
  "playwright>=1.64.0",
39
40
  "pytest>=8.0",
40
41
  "ruff==0.8.4",
42
+ "types-psutil>=7.2.2.20260906",
41
43
  ]
42
44
 
43
45
  [tool.hatch.version]
@@ -0,0 +1,3 @@
1
+ from labnotes.cli import main
2
+
3
+ main(prog_name="labnotes")
@@ -3,10 +3,12 @@ from pathlib import Path
3
3
  import click
4
4
 
5
5
  from labnotes.context_guide import context_guide
6
+ from labnotes.daemon_command import daemon_command
6
7
  from labnotes.experiments import experiments
7
8
  from labnotes.init import init
8
9
  from labnotes.load_config import load_config
9
10
  from labnotes.read_config import CONFIG_FILE
11
+ from labnotes.run_command import run_command
10
12
  from labnotes.server import server
11
13
 
12
14
 
@@ -37,6 +39,8 @@ def context(ctx: click.Context) -> None:
37
39
  click.echo(context_guide(load_config(ctx), Path.cwd()), nl=False)
38
40
 
39
41
 
42
+ main.add_command(daemon_command)
40
43
  main.add_command(experiments)
41
44
  main.add_command(init)
45
+ main.add_command(run_command)
42
46
  main.add_command(server)
@@ -105,18 +105,24 @@ running run under the cursor; `x` again kills it. `+` and
105
105
  `-` change how many run at once; lowering it lets running runs
106
106
  finish. Enter on a run follows its `stdout.log` and `stderr.log` together, as
107
107
  they're written, and Escape returns to the table. An `adhoc` experiment that
108
- plans a single run opens straight on its output. `q` quits. Elsewhere, it prints a
109
- line per event instead.
108
+ plans a single run opens straight on its output. `q` quits and leaves the runs
109
+ going. Elsewhere, it prints a line per event instead.
110
110
 
111
111
  - `--concurrency N` runs N at a time to start with. Without it, `concurrency`
112
112
  in `experiment.toml` sets it, then `concurrency` under `[experiments]` in
113
113
  `.labnotes.toml`, then 1.
114
114
  - `-e PATH` reads another file instead of `experiment.toml`.
115
+ - `run` hands its runs to the project's labnotes daemon, starting it if
116
+ needed, and shows or waits for them. The daemon listens on `.labnotes.sock`
117
+ in the project folder and exits once nothing is running and no client has
118
+ been connected for 15 seconds. Runs belong to the daemon, so quitting the
119
+ live view, closing its terminal, or killing `run` outright leaves them going.
115
120
  - Ctrl-C stops the running scripts and records their end. A second Ctrl-C
116
- kills them. Closing the terminal or sending SIGTERM stops them the same way,
117
- then kills any still going 5 seconds later. Once the runs are done, either
118
- one closes the live view.
119
- - A second `run` of the same experiment fails while the first holds the lock.
121
+ kills them. SIGTERM stops them the same way, then kills any still going 5
122
+ seconds later. Once the runs are done, either one closes the live view.
123
+ - `labnotes run stop` stops every run the daemon has going: SIGTERM, then
124
+ SIGKILL 5 seconds later.
125
+ - A second `run` of the same experiment fails while the first is going.
120
126
  - It exits 0 even if runs fail; the summary line counts failures.
121
127
 
122
128
  `uvx labnotes server` serves the experiments, their markdown files and their runs as a local site, read from the same `.labnotes.toml`. An experiment's page shows its `experiment.toml` on one line, then a Runs tab with a table of its runs and a Notebooks tab with one tab per markdown file.
@@ -0,0 +1,154 @@
1
+ import asyncio
2
+ import dataclasses
3
+ import itertools
4
+ import json
5
+ from collections.abc import AsyncIterator
6
+ from pathlib import Path
7
+
8
+ from starlette.applications import Starlette
9
+ from starlette.requests import Request
10
+ from starlette.responses import JSONResponse, Response, StreamingResponse
11
+ from starlette.routing import Route
12
+
13
+ from labnotes.models import Controls, LabnotesConfig
14
+ from labnotes.runner.prepare_experiment import prepare_experiment
15
+ from labnotes.runner.run_experiment import run_experiment
16
+ from labnotes.runner.stop_then_kill import GRACE_SECONDS
17
+
18
+ Event = dict[str, object]
19
+ ACTIONS = frozenset(field.name for field in dataclasses.fields(Controls))
20
+
21
+
22
+ def daemon_app(config: LabnotesConfig) -> Starlette:
23
+ sessions: dict[str, Controls | None] = {}
24
+ owners: dict[str, int] = {}
25
+ clients: dict[int, asyncio.Queue[Event]] = {}
26
+ ids = itertools.count(1)
27
+ tasks: set[asyncio.Task[str | None]] = set()
28
+
29
+ def broadcast(event: Event) -> None:
30
+ for queue in clients.values():
31
+ queue.put_nowait(event)
32
+
33
+ def refuse(status: int, error: str) -> Response:
34
+ return JSONResponse({"error": error}, status_code=status)
35
+
36
+ def release(client: int) -> None:
37
+ for name, owner in list(owners.items()):
38
+ controls = sessions.get(name)
39
+ if owner == client and controls is not None:
40
+ del owners[name]
41
+ controls.finish()
42
+
43
+ async def events(request: Request) -> Response:
44
+ client = next(ids)
45
+
46
+ async def stream() -> AsyncIterator[str]:
47
+ clients[client] = asyncio.Queue()
48
+ try:
49
+ event: Event = {"event": "connected", "client": client}
50
+ while True:
51
+ yield f"data: {json.dumps(event, default=str)}\n\n"
52
+ event = await clients[client].get()
53
+ finally:
54
+ del clients[client]
55
+ release(client)
56
+
57
+ return StreamingResponse(stream(), media_type="text/event-stream")
58
+
59
+ async def start(name: str, body: dict[str, object]) -> Response:
60
+ path = body.get("experiment_config")
61
+ try:
62
+ experiment, concurrency = await prepare_experiment(
63
+ config,
64
+ name,
65
+ None if path is None else Path(str(path)),
66
+ body.get("concurrency"), # type: ignore[arg-type]
67
+ )
68
+ except ValueError as error:
69
+ return refuse(400, str(error))
70
+ ready = asyncio.Event()
71
+ owner = body.get("client")
72
+ if isinstance(owner, int):
73
+ owners[name] = owner
74
+
75
+ def listen(controls: Controls) -> None:
76
+ sessions[name] = controls
77
+ ready.set()
78
+ if name in owners and owners[name] not in clients:
79
+ release(owners[name])
80
+
81
+ async def session() -> str | None:
82
+ error = None
83
+ try:
84
+ await run_experiment(
85
+ config,
86
+ name,
87
+ experiment,
88
+ concurrency,
89
+ lambda event: broadcast({**event, "experiment": name}),
90
+ listen,
91
+ )
92
+ # Clients hear why the session ended; the daemon keeps serving.
93
+ except Exception as failure: # noqa: BLE001
94
+ error = str(failure)
95
+ del sessions[name]
96
+ owners.pop(name, None)
97
+ broadcast({"event": "done", "experiment": name, "error": error})
98
+ return error
99
+
100
+ task = asyncio.create_task(session())
101
+ tasks.add(task)
102
+ task.add_done_callback(tasks.discard)
103
+ waiting = asyncio.create_task(ready.wait())
104
+ await asyncio.wait({task, waiting}, return_when=asyncio.FIRST_COMPLETED)
105
+ waiting.cancel()
106
+ if not ready.is_set():
107
+ return refuse(500, str(task.result()))
108
+ return JSONResponse({}, status_code=202)
109
+
110
+ async def run(request: Request) -> Response:
111
+ name = request.path_params["name"]
112
+ body = await request.json()
113
+ if name in sessions:
114
+ return refuse(409, f"{name} is already running.")
115
+ sessions[name] = None
116
+ response = await start(name, body)
117
+ if response.status_code != 202:
118
+ sessions.pop(name, None)
119
+ return response
120
+
121
+ async def control(request: Request) -> Response:
122
+ name, action = request.path_params["name"], request.path_params["action"]
123
+ body = await request.json()
124
+ controls = sessions.get(name)
125
+ if action not in ACTIONS or controls is None:
126
+ return refuse(404, f"No {action} for {name}.")
127
+ getattr(controls, action)(*body["args"])
128
+ return JSONResponse({})
129
+
130
+ def stop_all() -> None:
131
+ stopping = [controls for controls in sessions.values() if controls]
132
+
133
+ def interrupt() -> None:
134
+ for controls in stopping:
135
+ controls.interrupt()
136
+
137
+ interrupt()
138
+ asyncio.get_running_loop().call_later(GRACE_SECONDS, interrupt)
139
+
140
+ async def stop(request: Request) -> Response:
141
+ stop_all()
142
+ return JSONResponse({})
143
+
144
+ app = Starlette(
145
+ routes=[
146
+ Route("/events", events),
147
+ Route("/stop", stop, methods=["POST"]),
148
+ Route("/experiments/{name}/run", run, methods=["POST"]),
149
+ Route("/experiments/{name}/{action}", control, methods=["POST"]),
150
+ ]
151
+ )
152
+ app.state.idle = lambda: not clients and not sessions
153
+ app.state.stop = stop_all
154
+ return app
@@ -0,0 +1,12 @@
1
+ from pathlib import Path
2
+
3
+ import httpx2
4
+
5
+ from labnotes.daemon.socket_path import socket_path
6
+
7
+
8
+ def daemon_client(root: Path) -> httpx2.AsyncClient:
9
+ transport = httpx2.AsyncHTTPTransport(uds=str(socket_path(root)))
10
+ return httpx2.AsyncClient(
11
+ transport=transport, base_url="http://labnotes", timeout=None
12
+ )
@@ -0,0 +1,14 @@
1
+ import asyncio
2
+ from pathlib import Path
3
+
4
+ from labnotes.daemon.socket_path import socket_path
5
+
6
+
7
+ async def daemon_running(root: Path) -> bool:
8
+ try:
9
+ _, writer = await asyncio.open_unix_connection(socket_path(root))
10
+ except (FileNotFoundError, ConnectionRefusedError):
11
+ return False
12
+ writer.close()
13
+ await writer.wait_closed()
14
+ return True
@@ -0,0 +1,12 @@
1
+ from collections.abc import Iterator
2
+ from contextlib import contextmanager
3
+
4
+ import uvicorn
5
+
6
+
7
+ class DaemonServer(uvicorn.Server):
8
+ # A signal stops the runs through the daemon's own handlers; uvicorn's
9
+ # would shut the server down under them.
10
+ @contextmanager
11
+ def capture_signals(self) -> Iterator[None]:
12
+ yield
@@ -0,0 +1,26 @@
1
+ import asyncio
2
+ import subprocess
3
+ import sys
4
+
5
+ from labnotes.daemon.daemon_running import daemon_running
6
+ from labnotes.models import LabnotesConfig
7
+
8
+ START_SECONDS = 10
9
+
10
+
11
+ async def ensure_daemon(config: LabnotesConfig) -> None:
12
+ if await daemon_running(config.root):
13
+ return
14
+ subprocess.Popen(
15
+ [sys.executable, "-m", "labnotes", "-c", str(config.path.resolve()), "daemon"],
16
+ cwd=config.root,
17
+ stdin=subprocess.DEVNULL,
18
+ stdout=subprocess.DEVNULL,
19
+ stderr=subprocess.DEVNULL,
20
+ start_new_session=True,
21
+ )
22
+ for _ in range(START_SECONDS * 10):
23
+ await asyncio.sleep(0.1)
24
+ if await daemon_running(config.root):
25
+ return
26
+ raise ValueError(f"The labnotes daemon did not start in {START_SECONDS}s.")
@@ -0,0 +1,15 @@
1
+ import asyncio
2
+ from collections.abc import Callable
3
+
4
+ import uvicorn
5
+
6
+ # Long enough for a TUI restart or a mod reload to reconnect first.
7
+ IDLE_EXIT_SECONDS = 15
8
+
9
+
10
+ async def exit_when_idle(server: uvicorn.Server, idle: Callable[[], bool]) -> None:
11
+ quiet = 0
12
+ while quiet < IDLE_EXIT_SECONDS:
13
+ await asyncio.sleep(1)
14
+ quiet = quiet + 1 if idle() else 0
15
+ server.should_exit = True
@@ -0,0 +1,70 @@
1
+ import asyncio
2
+ import dataclasses
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+
6
+ import httpx2
7
+
8
+ from labnotes.daemon.daemon_client import daemon_client
9
+ from labnotes.daemon.ensure_daemon import ensure_daemon
10
+ from labnotes.models import Controls, LabnotesConfig, Listen, Report, Start
11
+
12
+
13
+ def remote_start(
14
+ config: LabnotesConfig,
15
+ name: str,
16
+ experiment_config: Path | None,
17
+ concurrency: int | None,
18
+ ) -> Start:
19
+ async def start(report: Report, listen: Listen) -> list[dict[str, object]]:
20
+ await ensure_daemon(config)
21
+ sending: set[asyncio.Task[httpx2.Response]] = set()
22
+ results: list[dict[str, object]] = []
23
+ async with (
24
+ daemon_client(config.root) as client,
25
+ client.stream("GET", "/events") as response,
26
+ ):
27
+ events = aiter(httpx2.EventSource(response))
28
+ connected: dict[str, object] = (await anext(events)).json() # type: ignore[assignment]
29
+ reply = await client.post(
30
+ f"/experiments/{name}/run",
31
+ json={
32
+ "client": connected["client"],
33
+ "experiment_config": None
34
+ if experiment_config is None
35
+ else str(experiment_config.resolve()),
36
+ "concurrency": concurrency,
37
+ },
38
+ )
39
+ if reply.status_code != 202:
40
+ raise ValueError(reply.json()["error"])
41
+
42
+ def send(action: str) -> Callable[..., None]:
43
+ def post(*args: object) -> None:
44
+ task = asyncio.create_task(
45
+ client.post(
46
+ f"/experiments/{name}/{action}", json={"args": list(args)}
47
+ )
48
+ )
49
+ sending.add(task)
50
+ task.add_done_callback(sending.discard)
51
+
52
+ return post
53
+
54
+ listen(Controls(*(send(f.name) for f in dataclasses.fields(Controls))))
55
+ async for message in events:
56
+ event: dict[str, object] = message.json() # type: ignore[assignment]
57
+ if event.pop("experiment", None) != name:
58
+ continue
59
+ match event:
60
+ case {"event": "done", "error": None}:
61
+ await asyncio.gather(*sending)
62
+ return results
63
+ case {"event": "done", "error": error}:
64
+ raise ValueError(f"{name} stopped: {error}")
65
+ case {"event": "ended"}:
66
+ results.append(event)
67
+ report(event)
68
+ raise ValueError("Lost the labnotes daemon.")
69
+
70
+ return start
@@ -0,0 +1,27 @@
1
+ import asyncio
2
+ import signal
3
+
4
+ import uvicorn
5
+
6
+ from labnotes.daemon.daemon_app import daemon_app
7
+ from labnotes.daemon.daemon_server import DaemonServer
8
+ from labnotes.daemon.exit_when_idle import exit_when_idle
9
+ from labnotes.daemon.socket_path import socket_path
10
+ from labnotes.models import LabnotesConfig
11
+
12
+
13
+ async def run_daemon(config: LabnotesConfig) -> None:
14
+ path = socket_path(config.root)
15
+ app = daemon_app(config)
16
+ server = DaemonServer(
17
+ uvicorn.Config(app, uds=str(path), lifespan="off", log_level="warning")
18
+ )
19
+ loop = asyncio.get_running_loop()
20
+ for signal_number in (signal.SIGTERM, signal.SIGHUP):
21
+ loop.add_signal_handler(signal_number, app.state.stop)
22
+ watcher = asyncio.create_task(exit_when_idle(server, app.state.idle))
23
+ try:
24
+ await server.serve()
25
+ finally:
26
+ watcher.cancel()
27
+ path.unlink(missing_ok=True)
@@ -0,0 +1,16 @@
1
+ import asyncio
2
+ import contextlib
3
+
4
+ from labnotes.daemon.run_daemon import run_daemon
5
+ from labnotes.models import LabnotesConfig
6
+ from labnotes.runner.hold_folder import hold_folder
7
+
8
+
9
+ def serve_daemon(config: LabnotesConfig) -> None:
10
+ lock = contextlib.ExitStack()
11
+ try:
12
+ lock.enter_context(hold_folder(config.root))
13
+ except BlockingIOError:
14
+ return # Another daemon already serves this project.
15
+ with lock:
16
+ asyncio.run(run_daemon(config))
@@ -0,0 +1,7 @@
1
+ from pathlib import Path
2
+
3
+ SOCKET_NAME = ".labnotes.sock"
4
+
5
+
6
+ def socket_path(root: Path) -> Path:
7
+ return root / SOCKET_NAME
@@ -0,0 +1,12 @@
1
+ from pathlib import Path
2
+
3
+ from labnotes.daemon.daemon_client import daemon_client
4
+ from labnotes.daemon.daemon_running import daemon_running
5
+
6
+
7
+ async def stop_runs(root: Path) -> bool:
8
+ if not await daemon_running(root):
9
+ return False
10
+ async with daemon_client(root) as client:
11
+ (await client.post("/stop")).raise_for_status()
12
+ return True
@@ -0,0 +1,14 @@
1
+ import click
2
+
3
+ from labnotes.daemon.serve_daemon import serve_daemon
4
+ from labnotes.load_config import load_config
5
+ from labnotes.read_config import CONFIG_FILE
6
+
7
+
8
+ @click.command(name="daemon", hidden=True)
9
+ @click.pass_context
10
+ def daemon_command(ctx: click.Context) -> None:
11
+ config = load_config(ctx)
12
+ if config is None:
13
+ raise click.ClickException(f"No {CONFIG_FILE} here.")
14
+ serve_daemon(config)
@@ -5,11 +5,11 @@ from pathlib import Path
5
5
  import click
6
6
 
7
7
  from labnotes.create_experiment import create_experiment
8
+ from labnotes.daemon.remote_start import remote_start
8
9
  from labnotes.load_config import load_config
9
10
  from labnotes.print_rows import print_rows
10
11
  from labnotes.read_config import CONFIG_FILE
11
12
  from labnotes.runner.run_headless import run_headless
12
- from labnotes.runner.start_experiment import start_experiment
13
13
  from labnotes.tables.experiment_rows import experiment_rows
14
14
  from labnotes.tui.run_tui import run_tui
15
15
 
@@ -62,12 +62,12 @@ def experiments(
62
62
  click.echo(f"Created {path / 'experiment.toml'}")
63
63
  return
64
64
  case str(), "run":
65
- live = sys.stdout.isatty() and sys.stderr.isatty()
66
- drive = run_tui if live else lambda start, title: run_headless(start)
65
+ start = remote_start(config, name, experiment_config, concurrency)
67
66
  try:
68
- results = start_experiment(
69
- config, name, experiment_config, concurrency, drive
70
- )
67
+ if sys.stdout.isatty() and sys.stderr.isatty():
68
+ results = run_tui(start, name)
69
+ else:
70
+ results = run_headless(start)
71
71
  except ValueError as error:
72
72
  raise click.ClickException(str(error)) from error
73
73
  failed = sum(result["exit_code"] != 0 for result in results)
@@ -0,0 +1,26 @@
1
+ import asyncio
2
+
3
+ import click
4
+
5
+ from labnotes.daemon.stop_runs import stop_runs
6
+ from labnotes.load_config import load_config
7
+ from labnotes.read_config import CONFIG_FILE
8
+
9
+
10
+ @click.group(name="run", help="Control the runs going in this project.")
11
+ def run_command() -> None:
12
+ pass
13
+
14
+
15
+ @run_command.command(
16
+ help="Stop every run. Scripts get SIGTERM, then SIGKILL 5 seconds later."
17
+ )
18
+ @click.pass_context
19
+ def stop(ctx: click.Context) -> None:
20
+ config = load_config(ctx)
21
+ if config is None:
22
+ raise click.ClickException(
23
+ f"No {CONFIG_FILE} here. Run labnotes init, or pass -c PATH."
24
+ )
25
+ stopping = asyncio.run(stop_runs(config.root))
26
+ click.echo("Stopping runs." if stopping else "Nothing is running.")
@@ -2,8 +2,10 @@ import asyncio
2
2
  import time
3
3
  from pathlib import Path
4
4
 
5
+ import psutil
6
+
5
7
  from labnotes.runner.shell_command import shell_command
6
- from labnotes.runner.wait_for_group import wait_for_group
8
+ from labnotes.runner.wait_for_tree import wait_for_tree
7
9
  from labnotes.runner.write_run_json import write_run_json
8
10
  from labnotes.tables.iso_time import iso_time
9
11
 
@@ -15,6 +17,7 @@ async def execute_run(
15
17
  params: dict[str, object],
16
18
  running: set[asyncio.subprocess.Process],
17
19
  stopping: asyncio.Event,
20
+ tree: set[psutil.Process],
18
21
  ) -> dict[str, object]:
19
22
  run: dict[str, object] = {
20
23
  "params": params,
@@ -34,9 +37,6 @@ async def execute_run(
34
37
  cwd=root,
35
38
  stdout=stdout,
36
39
  stderr=stderr,
37
- # Own process group, so Ctrl-C reaches labnotes first and the
38
- # runner decides how each run stops.
39
- start_new_session=True,
40
40
  )
41
41
  except OSError as error:
42
42
  run["error"] = str(error)
@@ -45,10 +45,10 @@ async def execute_run(
45
45
  try:
46
46
  run["exit_code"] = await process.wait()
47
47
  # The shell can exit on SIGTERM before what it started. Once
48
- # stopping, the run stays killable until its whole group is
49
- # gone. A normal exit ends the run even if helpers linger.
48
+ # stopping, the run stays killable until everything signalled
49
+ # is gone. A normal exit ends the run even if helpers linger.
50
50
  if stopping.is_set():
51
- await wait_for_group(process.pid)
51
+ await wait_for_tree(tree)
52
52
  finally:
53
53
  running.discard(process)
54
54
  run["ended"] = iso_time(time.time())
@@ -0,0 +1,23 @@
1
+ from pathlib import Path
2
+
3
+ from labnotes.models import ExperimentConfig, LabnotesConfig
4
+ from labnotes.runner.read_experiment_config import read_experiment_config
5
+ from labnotes.tables.file_text import file_text
6
+
7
+
8
+ async def prepare_experiment(
9
+ config: LabnotesConfig,
10
+ name: str,
11
+ experiment_config: Path | None,
12
+ concurrency: int | None,
13
+ ) -> tuple[ExperimentConfig, int]:
14
+ if experiment_config is None:
15
+ folder = config.experiments.replace("{experiment}", name)
16
+ experiment_config = config.root / folder / "experiment.toml"
17
+ source = await file_text(config.root, experiment_config)
18
+ if source is None:
19
+ raise ValueError(f"No experiment.toml for {name} at {experiment_config}.")
20
+ experiment = read_experiment_config(
21
+ experiment_config, source, config.mode, config.concurrency
22
+ )
23
+ return experiment, experiment.concurrency if concurrency is None else concurrency