labnotes 0.0.68__tar.gz → 0.0.70__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.68 → labnotes-0.0.70}/PKG-INFO +3 -1
  2. {labnotes-0.0.68 → labnotes-0.0.70}/pyproject.toml +3 -1
  3. labnotes-0.0.70/src/labnotes/__main__.py +3 -0
  4. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/cli.py +4 -0
  5. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/context.md +9 -1
  6. labnotes-0.0.70/src/labnotes/daemon/daemon_app.py +145 -0
  7. labnotes-0.0.70/src/labnotes/daemon/daemon_client.py +12 -0
  8. labnotes-0.0.70/src/labnotes/daemon/daemon_running.py +14 -0
  9. labnotes-0.0.70/src/labnotes/daemon/daemon_server.py +12 -0
  10. labnotes-0.0.70/src/labnotes/daemon/ensure_daemon.py +26 -0
  11. labnotes-0.0.70/src/labnotes/daemon/exit_when_idle.py +15 -0
  12. labnotes-0.0.70/src/labnotes/daemon/remote_start.py +69 -0
  13. labnotes-0.0.70/src/labnotes/daemon/run_daemon.py +27 -0
  14. labnotes-0.0.70/src/labnotes/daemon/serve_daemon.py +16 -0
  15. labnotes-0.0.70/src/labnotes/daemon/socket_path.py +7 -0
  16. labnotes-0.0.70/src/labnotes/daemon/stop_runs.py +12 -0
  17. labnotes-0.0.70/src/labnotes/daemon_command.py +14 -0
  18. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/experiments.py +9 -5
  19. labnotes-0.0.70/src/labnotes/run_command.py +26 -0
  20. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/runner/execute_run.py +7 -7
  21. labnotes-0.0.70/src/labnotes/runner/prepare_experiment.py +30 -0
  22. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/runner/read_experiment_config.py +4 -3
  23. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/runner/run_experiment.py +14 -11
  24. labnotes-0.0.70/src/labnotes/runner/signal_tree.py +12 -0
  25. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/runner/start_experiment.py +4 -13
  26. labnotes-0.0.70/src/labnotes/runner/wait_for_tree.py +8 -0
  27. labnotes-0.0.70/src/labnotes/tables/experiment_run_ids.py +11 -0
  28. labnotes-0.0.70/src/labnotes/tables/file_text.py +12 -0
  29. labnotes-0.0.70/src/labnotes/tui/__init__.py +0 -0
  30. {labnotes-0.0.68 → labnotes-0.0.70}/testing-conventions.toml +18 -0
  31. {labnotes-0.0.68 → labnotes-0.0.70}/uv.lock +15 -2
  32. labnotes-0.0.68/src/labnotes/runner/wait_for_group.py +0 -11
  33. {labnotes-0.0.68 → labnotes-0.0.70}/.gitignore +0 -0
  34. {labnotes-0.0.68 → labnotes-0.0.70}/CHANGELOG.md +0 -0
  35. {labnotes-0.0.68 → labnotes-0.0.70}/MIGRATIONS.md +0 -0
  36. {labnotes-0.0.68 → labnotes-0.0.70}/README.md +0 -0
  37. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/__init__.py +0 -0
  38. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/asset_url.py +0 -0
  39. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/check_counts.py +0 -0
  40. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/context_guide.py +0 -0
  41. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/count_runs.py +0 -0
  42. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/create_app.py +0 -0
  43. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/create_experiment.py +0 -0
  44. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/create_note.py +0 -0
  45. {labnotes-0.0.68/src/labnotes/runner → labnotes-0.0.70/src/labnotes/daemon}/__init__.py +0 -0
  46. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/data_html.py +0 -0
  47. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/default_config_text.py +0 -0
  48. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/default_counts.py +0 -0
  49. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/drain_watch.py +0 -0
  50. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/experiment_notes.py +0 -0
  51. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/experiment_runs.py +0 -0
  52. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/file_changes.py +0 -0
  53. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/format_duration.py +0 -0
  54. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/format_value.py +0 -0
  55. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/group_runs.py +0 -0
  56. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/init.py +0 -0
  57. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/invocation_started.py +0 -0
  58. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/is_positive_int.py +0 -0
  59. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/list_experiments.py +0 -0
  60. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/list_runs.py +0 -0
  61. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/load_config.py +0 -0
  62. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/load_experiment.py +0 -0
  63. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/models.py +0 -0
  64. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/modes.py +0 -0
  65. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/nests_runs.py +0 -0
  66. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/note_path.py +0 -0
  67. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/notebook_kernel.py +0 -0
  68. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/output_html.py +0 -0
  69. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/parse_run_view.py +0 -0
  70. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/print_rows.py +0 -0
  71. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/python_blocks.py +0 -0
  72. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/read_config.py +0 -0
  73. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/read_path.py +0 -0
  74. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/render_markdown.py +0 -0
  75. {labnotes-0.0.68/src/labnotes/tables → labnotes-0.0.70/src/labnotes/runner}/__init__.py +0 -0
  76. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/runner/describe_event.py +0 -0
  77. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/runner/hold_folder.py +0 -0
  78. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/runner/new_param_run_id.py +0 -0
  79. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/runner/new_run_id.py +0 -0
  80. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  81. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/runner/param_args.py +0 -0
  82. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/runner/param_grid.py +0 -0
  83. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/runner/param_run_name.py +0 -0
  84. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/runner/plan_runs.py +0 -0
  85. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/runner/run_folder.py +0 -0
  86. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/runner/run_headless.py +0 -0
  87. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/runner/runs_parent.py +0 -0
  88. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/runner/shell_command.py +0 -0
  89. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/runner/stop_then_kill.py +0 -0
  90. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/runner/write_run_json.py +0 -0
  91. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/save_conflict_copy.py +0 -0
  92. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/save_note.py +0 -0
  93. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/saved_outputs.py +0 -0
  94. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/serve_app.py +0 -0
  95. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/server.py +0 -0
  96. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/sort_href.py +0 -0
  97. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/sql_quote.py +0 -0
  98. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/static/experiment.js +0 -0
  99. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/static/labnotes.css +0 -0
  100. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/static/new-note.js +0 -0
  101. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/static/note-edit.js +0 -0
  102. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/static/note-editor.js +0 -0
  103. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/static/row-link.js +0 -0
  104. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/static/run-cell.js +0 -0
  105. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/static/view-toggle.js +0 -0
  106. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/summarize_config.py +0 -0
  107. {labnotes-0.0.68/src/labnotes/tui → labnotes-0.0.70/src/labnotes/tables}/__init__.py +0 -0
  108. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/tables/experiment_rows.py +0 -0
  109. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/tables/file_prefix.py +0 -0
  110. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/tables/iso_time.py +0 -0
  111. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/tables/list_experiment_runs.py +0 -0
  112. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/tables/note_text.py +0 -0
  113. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/tables/open_tables.py +0 -0
  114. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/tables/path_glob.py +0 -0
  115. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/tables/path_pattern.py +0 -0
  116. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/tables/read_experiment.py +0 -0
  117. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/tables/read_file.py +0 -0
  118. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/tables/read_run.py +0 -0
  119. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/tables/summarize_experiments.py +0 -0
  120. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/templates/base.html +0 -0
  121. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/templates/editor_imports.html +0 -0
  122. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/templates/experiment.html +0 -0
  123. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/templates/index.html +0 -0
  124. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/templates/note.html +0 -0
  125. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/tui/dim.py +0 -0
  126. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/tui/format_elapsed.py +0 -0
  127. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/tui/run_app.py +0 -0
  128. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/tui/run_tui.py +0 -0
  129. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/tui/runs_table.py +0 -0
  130. {labnotes-0.0.68 → labnotes-0.0.70}/src/labnotes/wait_for_note.py +0 -0
  131. {labnotes-0.0.68 → labnotes-0.0.70}/tests/integration/conftest.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.68
3
+ Version: 0.0.70
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)
@@ -116,7 +116,15 @@ line per event instead.
116
116
  kills them. Closing the terminal or sending SIGTERM stops them the same way,
117
117
  then kills any still going 5 seconds later. Once the runs are done, either
118
118
  one closes the live view.
119
- - A second `run` of the same experiment fails while the first holds the lock.
119
+ - Outside a terminal, `run` hands its runs to the project's labnotes daemon,
120
+ starting it if needed, and waits for them. The daemon listens on
121
+ `.labnotes.sock` in the project folder and exits once nothing is running and
122
+ no client has been connected for 15 seconds. Runs belong to the daemon, so
123
+ killing the waiting `run` outright leaves them going. Ctrl-C or SIGTERM to it
124
+ still stops them.
125
+ - `labnotes run stop` stops every run the daemon has going: SIGTERM, then
126
+ SIGKILL 5 seconds later.
127
+ - A second `run` of the same experiment fails while the first is going.
120
128
  - It exits 0 even if runs fail; the summary line counts failures.
121
129
 
122
130
  `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,145 @@
1
+ import asyncio
2
+ import contextlib
3
+ import dataclasses
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.hold_folder import hold_folder
15
+ from labnotes.runner.prepare_experiment import prepare_experiment
16
+ from labnotes.runner.run_experiment import run_experiment
17
+ from labnotes.runner.stop_then_kill import GRACE_SECONDS
18
+
19
+ Event = dict[str, object]
20
+ ACTIONS = frozenset(field.name for field in dataclasses.fields(Controls))
21
+
22
+
23
+ def daemon_app(config: LabnotesConfig) -> Starlette:
24
+ sessions: dict[str, Controls | None] = {}
25
+ clients: set[asyncio.Queue[Event]] = set()
26
+ tasks: set[asyncio.Task[str | None]] = set()
27
+
28
+ def broadcast(event: Event) -> None:
29
+ for queue in clients:
30
+ queue.put_nowait(event)
31
+
32
+ def refuse(status: int, error: str) -> Response:
33
+ return JSONResponse({"error": error}, status_code=status)
34
+
35
+ async def events(request: Request) -> Response:
36
+ queue: asyncio.Queue[Event] = asyncio.Queue()
37
+
38
+ async def stream() -> AsyncIterator[str]:
39
+ clients.add(queue)
40
+ try:
41
+ event: Event = {"event": "connected"}
42
+ while True:
43
+ yield f"data: {json.dumps(event, default=str)}\n\n"
44
+ event = await queue.get()
45
+ finally:
46
+ clients.discard(queue)
47
+
48
+ return StreamingResponse(stream(), media_type="text/event-stream")
49
+
50
+ async def start(name: str, body: dict[str, object]) -> Response:
51
+ path = body.get("experiment_config")
52
+ try:
53
+ experiment, concurrency, runs = await prepare_experiment(
54
+ config,
55
+ name,
56
+ None if path is None else Path(str(path)),
57
+ body.get("concurrency"), # type: ignore[arg-type]
58
+ )
59
+ except ValueError as error:
60
+ return refuse(400, str(error))
61
+ lock = contextlib.ExitStack()
62
+ try:
63
+ lock.enter_context(hold_folder(runs))
64
+ except BlockingIOError:
65
+ return refuse(409, f"{name} is already running ({runs} is held).")
66
+ ready = asyncio.Event()
67
+
68
+ def listen(controls: Controls) -> None:
69
+ sessions[name] = controls
70
+ ready.set()
71
+
72
+ async def session() -> str | None:
73
+ error = None
74
+ with lock:
75
+ try:
76
+ await run_experiment(
77
+ config,
78
+ name,
79
+ experiment,
80
+ concurrency,
81
+ lambda event: broadcast({**event, "experiment": name}),
82
+ listen,
83
+ )
84
+ # Clients hear why the session ended; the daemon keeps serving.
85
+ except Exception as failure: # noqa: BLE001
86
+ error = str(failure)
87
+ del sessions[name]
88
+ broadcast({"event": "done", "experiment": name, "error": error})
89
+ return error
90
+
91
+ task = asyncio.create_task(session())
92
+ tasks.add(task)
93
+ task.add_done_callback(tasks.discard)
94
+ waiting = asyncio.create_task(ready.wait())
95
+ await asyncio.wait({task, waiting}, return_when=asyncio.FIRST_COMPLETED)
96
+ waiting.cancel()
97
+ if not ready.is_set():
98
+ return refuse(500, str(task.result()))
99
+ return JSONResponse({}, status_code=202)
100
+
101
+ async def run(request: Request) -> Response:
102
+ name = request.path_params["name"]
103
+ body = await request.json()
104
+ if name in sessions:
105
+ return refuse(409, f"{name} is already running.")
106
+ sessions[name] = None
107
+ response = await start(name, body)
108
+ if response.status_code != 202:
109
+ sessions.pop(name, None)
110
+ return response
111
+
112
+ async def control(request: Request) -> Response:
113
+ name, action = request.path_params["name"], request.path_params["action"]
114
+ body = await request.json()
115
+ controls = sessions.get(name)
116
+ if action not in ACTIONS or controls is None:
117
+ return refuse(404, f"No {action} for {name}.")
118
+ getattr(controls, action)(*body["args"])
119
+ return JSONResponse({})
120
+
121
+ def stop_all() -> None:
122
+ stopping = [controls for controls in sessions.values() if controls]
123
+
124
+ def interrupt() -> None:
125
+ for controls in stopping:
126
+ controls.interrupt()
127
+
128
+ interrupt()
129
+ asyncio.get_running_loop().call_later(GRACE_SECONDS, interrupt)
130
+
131
+ async def stop(request: Request) -> Response:
132
+ stop_all()
133
+ return JSONResponse({})
134
+
135
+ app = Starlette(
136
+ routes=[
137
+ Route("/events", events),
138
+ Route("/stop", stop, methods=["POST"]),
139
+ Route("/experiments/{name}/run", run, methods=["POST"]),
140
+ Route("/experiments/{name}/{action}", control, methods=["POST"]),
141
+ ]
142
+ )
143
+ app.state.idle = lambda: not clients and not sessions
144
+ app.state.stop = stop_all
145
+ 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,69 @@
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
+ await anext(events)
29
+ reply = await client.post(
30
+ f"/experiments/{name}/run",
31
+ json={
32
+ "experiment_config": None
33
+ if experiment_config is None
34
+ else str(experiment_config.resolve()),
35
+ "concurrency": concurrency,
36
+ },
37
+ )
38
+ if reply.status_code != 202:
39
+ raise ValueError(reply.json()["error"])
40
+
41
+ def send(action: str) -> Callable[..., None]:
42
+ def post(*args: object) -> None:
43
+ task = asyncio.create_task(
44
+ client.post(
45
+ f"/experiments/{name}/{action}", json={"args": list(args)}
46
+ )
47
+ )
48
+ sending.add(task)
49
+ task.add_done_callback(sending.discard)
50
+
51
+ return post
52
+
53
+ listen(Controls(*(send(f.name) for f in dataclasses.fields(Controls))))
54
+ async for message in events:
55
+ event: dict[str, object] = message.json() # type: ignore[assignment]
56
+ if event.pop("experiment", None) != name:
57
+ continue
58
+ match event:
59
+ case {"event": "done", "error": None}:
60
+ await asyncio.gather(*sending)
61
+ return results
62
+ case {"event": "done", "error": error}:
63
+ raise ValueError(f"{name} stopped: {error}")
64
+ case {"event": "ended"}:
65
+ results.append(event)
66
+ report(event)
67
+ raise ValueError("Lost the labnotes daemon.")
68
+
69
+ 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,6 +5,7 @@ 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
@@ -62,12 +63,15 @@ def experiments(
62
63
  click.echo(f"Created {path / 'experiment.toml'}")
63
64
  return
64
65
  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)
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 = start_experiment(
69
+ config, name, experiment_config, concurrency, run_tui
70
+ )
71
+ else:
72
+ results = run_headless(
73
+ remote_start(config, name, experiment_config, concurrency)
74
+ )
71
75
  except ValueError as error:
72
76
  raise click.ClickException(str(error)) from error
73
77
  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,30 @@
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.runner.runs_parent import runs_parent
6
+ from labnotes.tables.file_text import file_text
7
+
8
+
9
+ async def prepare_experiment(
10
+ config: LabnotesConfig,
11
+ name: str,
12
+ experiment_config: Path | None,
13
+ concurrency: int | None,
14
+ ) -> tuple[ExperimentConfig, int, Path]:
15
+ if experiment_config is None:
16
+ folder = config.experiments.replace("{experiment}", name)
17
+ experiment_config = config.root / folder / "experiment.toml"
18
+ source = await file_text(config.root, experiment_config)
19
+ if source is None:
20
+ raise ValueError(f"No experiment.toml for {name} at {experiment_config}.")
21
+ experiment = read_experiment_config(
22
+ experiment_config, source, config.mode, config.concurrency
23
+ )
24
+ runs = runs_parent(config.root, config.runs, name)
25
+ runs.mkdir(parents=True, exist_ok=True)
26
+ return (
27
+ experiment,
28
+ experiment.concurrency if concurrency is None else concurrency,
29
+ runs,
30
+ )
@@ -10,10 +10,11 @@ from labnotes.modes import MODES
10
10
  SETTINGS = {"name", "command", "repeats", "params", "counts", "mode", "concurrency"}
11
11
 
12
12
 
13
- def read_experiment_config(path: Path, mode: str, concurrency: int) -> ExperimentConfig:
13
+ def read_experiment_config(
14
+ path: Path, source: str, mode: str, concurrency: int
15
+ ) -> ExperimentConfig:
14
16
  try:
15
- with path.open("rb") as file:
16
- values = tomllib.load(file)
17
+ values = tomllib.loads(source)
17
18
  except tomllib.TOMLDecodeError as error:
18
19
  raise ValueError(f"{path}: {error}") from error
19
20
  unknown = sorted(values.keys() - SETTINGS)