labnotes 0.0.69__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.
- {labnotes-0.0.69 → labnotes-0.0.70}/PKG-INFO +3 -1
- {labnotes-0.0.69 → labnotes-0.0.70}/pyproject.toml +3 -1
- labnotes-0.0.70/src/labnotes/__main__.py +3 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/cli.py +4 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/context.md +9 -1
- labnotes-0.0.70/src/labnotes/daemon/daemon_app.py +145 -0
- labnotes-0.0.70/src/labnotes/daemon/daemon_client.py +12 -0
- labnotes-0.0.70/src/labnotes/daemon/daemon_running.py +14 -0
- labnotes-0.0.70/src/labnotes/daemon/daemon_server.py +12 -0
- labnotes-0.0.70/src/labnotes/daemon/ensure_daemon.py +26 -0
- labnotes-0.0.70/src/labnotes/daemon/exit_when_idle.py +15 -0
- labnotes-0.0.70/src/labnotes/daemon/remote_start.py +69 -0
- labnotes-0.0.70/src/labnotes/daemon/run_daemon.py +27 -0
- labnotes-0.0.70/src/labnotes/daemon/serve_daemon.py +16 -0
- labnotes-0.0.70/src/labnotes/daemon/socket_path.py +7 -0
- labnotes-0.0.70/src/labnotes/daemon/stop_runs.py +12 -0
- labnotes-0.0.70/src/labnotes/daemon_command.py +14 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/experiments.py +9 -5
- labnotes-0.0.70/src/labnotes/run_command.py +26 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/runner/execute_run.py +7 -7
- labnotes-0.0.70/src/labnotes/runner/prepare_experiment.py +30 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/runner/run_experiment.py +8 -5
- labnotes-0.0.70/src/labnotes/runner/signal_tree.py +12 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/runner/start_experiment.py +3 -15
- labnotes-0.0.70/src/labnotes/runner/wait_for_tree.py +8 -0
- labnotes-0.0.70/src/labnotes/tui/__init__.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/testing-conventions.toml +18 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/uv.lock +15 -2
- labnotes-0.0.69/src/labnotes/runner/wait_for_group.py +0 -11
- {labnotes-0.0.69 → labnotes-0.0.70}/.gitignore +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/CHANGELOG.md +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/MIGRATIONS.md +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/README.md +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/__init__.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/asset_url.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/check_counts.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/context_guide.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/count_runs.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/create_app.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/create_experiment.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/create_note.py +0 -0
- {labnotes-0.0.69/src/labnotes/runner → labnotes-0.0.70/src/labnotes/daemon}/__init__.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/data_html.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/default_config_text.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/default_counts.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/drain_watch.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/experiment_notes.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/experiment_runs.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/file_changes.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/format_duration.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/format_value.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/group_runs.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/init.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/invocation_started.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/is_positive_int.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/list_experiments.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/list_runs.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/load_config.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/load_experiment.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/models.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/modes.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/nests_runs.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/note_path.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/notebook_kernel.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/output_html.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/parse_run_view.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/print_rows.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/python_blocks.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/read_config.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/read_path.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/render_markdown.py +0 -0
- {labnotes-0.0.69/src/labnotes/tables → labnotes-0.0.70/src/labnotes/runner}/__init__.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/runner/describe_event.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/runner/hold_folder.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/runner/new_param_run_id.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/runner/new_run_id.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/runner/new_stamped_run_id.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/runner/param_args.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/runner/param_grid.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/runner/param_run_name.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/runner/plan_runs.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/runner/read_experiment_config.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/runner/run_folder.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/runner/run_headless.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/runner/runs_parent.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/runner/shell_command.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/runner/stop_then_kill.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/runner/write_run_json.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/save_conflict_copy.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/save_note.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/saved_outputs.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/serve_app.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/server.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/sort_href.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/sql_quote.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/static/experiment.js +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/static/labnotes.css +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/static/new-note.js +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/static/note-edit.js +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/static/note-editor.js +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/static/row-link.js +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/static/run-cell.js +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/static/view-toggle.js +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/summarize_config.py +0 -0
- {labnotes-0.0.69/src/labnotes/tui → labnotes-0.0.70/src/labnotes/tables}/__init__.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/tables/experiment_rows.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/tables/experiment_run_ids.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/tables/file_prefix.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/tables/file_text.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/tables/iso_time.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/tables/list_experiment_runs.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/tables/note_text.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/tables/open_tables.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/tables/path_glob.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/tables/path_pattern.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/tables/read_experiment.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/tables/read_file.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/tables/read_run.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/tables/summarize_experiments.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/templates/base.html +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/templates/editor_imports.html +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/templates/experiment.html +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/templates/index.html +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/templates/note.html +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/tui/dim.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/tui/format_elapsed.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/tui/run_app.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/tui/run_tui.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/tui/runs_table.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.70}/src/labnotes/wait_for_note.py +0 -0
- {labnotes-0.0.69 → 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.
|
|
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]
|
|
@@ -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
|
-
-
|
|
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,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
|
-
|
|
69
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
+
)
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
-
import contextlib
|
|
3
2
|
import json
|
|
4
|
-
import os
|
|
5
3
|
import signal
|
|
6
4
|
|
|
5
|
+
import psutil
|
|
6
|
+
|
|
7
7
|
from labnotes.models import (
|
|
8
8
|
Controls,
|
|
9
9
|
ExperimentConfig,
|
|
@@ -17,6 +17,7 @@ from labnotes.runner.new_stamped_run_id import new_stamped_run_id
|
|
|
17
17
|
from labnotes.runner.param_grid import param_grid
|
|
18
18
|
from labnotes.runner.plan_runs import plan_runs
|
|
19
19
|
from labnotes.runner.run_folder import run_folder
|
|
20
|
+
from labnotes.runner.signal_tree import signal_tree
|
|
20
21
|
from labnotes.tables.experiment_run_ids import experiment_run_ids
|
|
21
22
|
from labnotes.tables.list_experiment_runs import list_experiment_runs
|
|
22
23
|
from labnotes.tables.open_tables import open_tables
|
|
@@ -70,14 +71,14 @@ async def run_experiment(
|
|
|
70
71
|
}
|
|
71
72
|
)
|
|
72
73
|
running: dict[str, set[asyncio.subprocess.Process]] = {}
|
|
74
|
+
trees: dict[str, set[psutil.Process]] = {}
|
|
73
75
|
stopped: set[str] = set()
|
|
74
76
|
results: list[dict[str, object]] = []
|
|
75
77
|
stopping = asyncio.Event()
|
|
76
78
|
|
|
77
79
|
def signal_run(run: str, signal_number: int) -> None:
|
|
78
80
|
for process in running[run]:
|
|
79
|
-
|
|
80
|
-
os.killpg(process.pid, signal_number)
|
|
81
|
+
signal_tree(process.pid, signal_number, trees[run])
|
|
81
82
|
|
|
82
83
|
def signal_runs(signal_number: int) -> None:
|
|
83
84
|
for run in running:
|
|
@@ -183,6 +184,7 @@ async def run_experiment(
|
|
|
183
184
|
folder.mkdir(parents=True)
|
|
184
185
|
report({"event": "started", "run": run, "params": params, "folder": folder})
|
|
185
186
|
running[run] = set()
|
|
187
|
+
trees[run] = set()
|
|
186
188
|
result = await execute_run(
|
|
187
189
|
config.root,
|
|
188
190
|
folder,
|
|
@@ -190,8 +192,9 @@ async def run_experiment(
|
|
|
190
192
|
params,
|
|
191
193
|
running[run],
|
|
192
194
|
stopping,
|
|
195
|
+
trees[run],
|
|
193
196
|
)
|
|
194
|
-
del running[run]
|
|
197
|
+
del running[run], trees[run]
|
|
195
198
|
report({"event": "ended", "run": run, **result})
|
|
196
199
|
ended[run] = params
|
|
197
200
|
results.append(result)
|