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.
- {labnotes-0.0.69 → labnotes-0.0.71}/PKG-INFO +3 -1
- {labnotes-0.0.69 → labnotes-0.0.71}/pyproject.toml +3 -1
- labnotes-0.0.71/src/labnotes/__main__.py +3 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/cli.py +4 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/context.md +12 -6
- labnotes-0.0.71/src/labnotes/daemon/daemon_app.py +154 -0
- labnotes-0.0.71/src/labnotes/daemon/daemon_client.py +12 -0
- labnotes-0.0.71/src/labnotes/daemon/daemon_running.py +14 -0
- labnotes-0.0.71/src/labnotes/daemon/daemon_server.py +12 -0
- labnotes-0.0.71/src/labnotes/daemon/ensure_daemon.py +26 -0
- labnotes-0.0.71/src/labnotes/daemon/exit_when_idle.py +15 -0
- labnotes-0.0.71/src/labnotes/daemon/remote_start.py +70 -0
- labnotes-0.0.71/src/labnotes/daemon/run_daemon.py +27 -0
- labnotes-0.0.71/src/labnotes/daemon/serve_daemon.py +16 -0
- labnotes-0.0.71/src/labnotes/daemon/socket_path.py +7 -0
- labnotes-0.0.71/src/labnotes/daemon/stop_runs.py +12 -0
- labnotes-0.0.71/src/labnotes/daemon_command.py +14 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/experiments.py +6 -6
- labnotes-0.0.71/src/labnotes/run_command.py +26 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/execute_run.py +7 -7
- labnotes-0.0.71/src/labnotes/runner/prepare_experiment.py +23 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/run_experiment.py +9 -5
- labnotes-0.0.71/src/labnotes/runner/signal_tree.py +12 -0
- labnotes-0.0.71/src/labnotes/runner/wait_for_tree.py +8 -0
- labnotes-0.0.71/src/labnotes/tui/__init__.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tui/run_app.py +5 -2
- {labnotes-0.0.69 → labnotes-0.0.71}/testing-conventions.toml +18 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/uv.lock +15 -2
- labnotes-0.0.69/src/labnotes/runner/start_experiment.py +0 -39
- labnotes-0.0.69/src/labnotes/runner/wait_for_group.py +0 -11
- {labnotes-0.0.69 → labnotes-0.0.71}/.gitignore +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/CHANGELOG.md +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/MIGRATIONS.md +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/README.md +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/__init__.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/asset_url.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/check_counts.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/context_guide.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/count_runs.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/create_app.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/create_experiment.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/create_note.py +0 -0
- {labnotes-0.0.69/src/labnotes/runner → labnotes-0.0.71/src/labnotes/daemon}/__init__.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/data_html.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/default_config_text.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/default_counts.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/drain_watch.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/experiment_notes.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/experiment_runs.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/file_changes.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/format_duration.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/format_value.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/group_runs.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/init.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/invocation_started.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/is_positive_int.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/list_experiments.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/list_runs.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/load_config.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/load_experiment.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/models.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/modes.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/nests_runs.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/note_path.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/notebook_kernel.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/output_html.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/parse_run_view.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/print_rows.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/python_blocks.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/read_config.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/read_path.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/render_markdown.py +0 -0
- {labnotes-0.0.69/src/labnotes/tables → labnotes-0.0.71/src/labnotes/runner}/__init__.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/describe_event.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/hold_folder.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/new_param_run_id.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/new_run_id.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/new_stamped_run_id.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/param_args.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/param_grid.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/param_run_name.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/plan_runs.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/read_experiment_config.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/run_folder.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/run_headless.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/runs_parent.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/shell_command.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/stop_then_kill.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/runner/write_run_json.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/save_conflict_copy.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/save_note.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/saved_outputs.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/serve_app.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/server.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/sort_href.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/sql_quote.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/static/experiment.js +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/static/labnotes.css +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/static/new-note.js +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/static/note-edit.js +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/static/note-editor.js +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/static/row-link.js +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/static/run-cell.js +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/static/view-toggle.js +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/summarize_config.py +0 -0
- {labnotes-0.0.69/src/labnotes/tui → labnotes-0.0.71/src/labnotes/tables}/__init__.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/experiment_rows.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/experiment_run_ids.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/file_prefix.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/file_text.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/iso_time.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/list_experiment_runs.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/note_text.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/open_tables.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/path_glob.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/path_pattern.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/read_experiment.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/read_file.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/read_run.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tables/summarize_experiments.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/templates/base.html +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/templates/editor_imports.html +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/templates/experiment.html +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/templates/index.html +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/templates/note.html +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tui/dim.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tui/format_elapsed.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tui/run_tui.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/tui/runs_table.py +0 -0
- {labnotes-0.0.69 → labnotes-0.0.71}/src/labnotes/wait_for_note.py +0 -0
- {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.
|
|
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]
|
|
@@ -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
|
|
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.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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,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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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.
|
|
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,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
|